From e491fc6b8ad9eef2afef52a01ca44d4e74bfb4d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:38:46 +0000 Subject: [PATCH 01/11] Initial plan From 4abc13a2f156bf8f70916efb4df82604fcf6913c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:13:35 +0000 Subject: [PATCH 02/11] feat: add GitHub App-only permissions support - Add 35 GitHub App-only PermissionScope constants (administration, members, organization-administration, secrets, environments, workflows, git-signing, vulnerability-alerts, repository-hooks, single-file, codespaces, dependabot-secrets, organization-hooks, organization-members, organization-packages, organization-secrets, organization-custom-org-roles, organization-custom-properties, organization-custom-repository-roles, organization-announcement-banners, organization-events, organization-plan, organization-user-blocking, organization-personal-access-token-requests, organization-personal-access-tokens, organization-copilot, organization-codespaces, email-addresses, codespaces-lifecycle-admin, codespaces-metadata, codespaces-secrets, repository-custom-properties, organization-administration, team-discussions, organization-self-hosted-runners) - Move organization-projects from GitHub Actions scopes to GitHub App-only scopes - Add GetAllGitHubAppOnlyScopes() and IsGitHubAppOnlyScope() functions - Update RenderToYAML() to skip GitHub App-only scopes using IsGitHubAppOnlyScope() - Update convertPermissionsToAppTokenFields() to map all new App-only permissions - Update PermissionsConfig struct and parser/serializer with all new fields - Add validateGitHubAppOnlyPermissions() validation requiring github-app config - Register new validation in compiler pipeline - Add comprehensive tests for all new functionality - Fix test counts for write-all shorthand expansion Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler.go | 6 + .../dangerous_permissions_validation_test.go | 2 +- pkg/workflow/frontmatter_types.go | 221 ++++++++- .../github_app_permissions_validation.go | 119 +++++ .../github_app_permissions_validation_test.go | 430 ++++++++++++++++++ pkg/workflow/permissions.go | 175 ++++++- pkg/workflow/permissions_operations.go | 6 +- pkg/workflow/permissions_operations_test.go | 131 +++--- pkg/workflow/safe_outputs_app_config.go | 129 +++++- 9 files changed, 1135 insertions(+), 84 deletions(-) create mode 100644 pkg/workflow/github_app_permissions_validation.go create mode 100644 pkg/workflow/github_app_permissions_validation_test.go diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 7e3723dc35d..4d6b44d2b2b 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -131,6 +131,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } + // Validate GitHub App-only permissions require a GitHub App to be configured + log.Printf("Validating GitHub App-only permissions") + if err := validateGitHubAppOnlyPermissions(workflowData); err != nil { + return formatCompilerError(markdownPath, "error", err.Error(), err) + } + // Validate agent file exists if specified in engine config log.Printf("Validating agent file if specified") if err := c.validateAgentFile(workflowData, markdownPath); err != nil { diff --git a/pkg/workflow/dangerous_permissions_validation_test.go b/pkg/workflow/dangerous_permissions_validation_test.go index 2fb31ce6f02..e627e0d5b45 100644 --- a/pkg/workflow/dangerous_permissions_validation_test.go +++ b/pkg/workflow/dangerous_permissions_validation_test.go @@ -153,7 +153,7 @@ func TestFindWritePermissions(t *testing.T) { { name: "write-all shorthand", permissions: NewPermissionsWriteAll(), - expectedWriteCount: 15, // All permission scopes except id-token (which is excluded) + expectedWriteCount: 14, // All GitHub Actions permission scopes except id-token and metadata (which are excluded) expectedScopes: nil, // Don't check specific scopes for shorthand }, { diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 66518fa8f3c..44d66e355ed 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -36,12 +36,14 @@ type RuntimesConfig struct { } // PermissionsConfig represents GitHub Actions permissions configuration -// Supports both shorthand (read-all, write-all) and detailed scope-based permissions +// Supports both shorthand (read-all, write-all) and detailed scope-based permissions. +// In addition to standard GitHub Actions scopes, this also supports GitHub App-only scopes +// (e.g., members, administration) for use when a GitHub App is configured. type PermissionsConfig struct { // Shorthand permission (read-all, write-all, read, write, none) Shorthand string `json:"-"` // Not in JSON, set when parsing shorthand format - // Detailed permissions by scope + // Detailed permissions by scope - GitHub Actions scopes (supported by GITHUB_TOKEN) Actions string `json:"actions,omitempty"` Checks string `json:"checks,omitempty"` Contents string `json:"contents,omitempty"` @@ -56,7 +58,46 @@ type PermissionsConfig struct { SecurityEvents string `json:"security-events,omitempty"` Statuses string `json:"statuses,omitempty"` OrganizationProjects string `json:"organization-projects,omitempty"` - OrganizationPackages string `json:"organization-packages,omitempty"` + + // GitHub App-only permission scopes (not supported by GITHUB_TOKEN). + // When any of these are specified, a GitHub App must be configured in the workflow. + // Repository-level + Administration string `json:"administration,omitempty"` + Secrets string `json:"secrets,omitempty"` + Environments string `json:"environments,omitempty"` + GitSigning string `json:"git-signing,omitempty"` + VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` + Workflows string `json:"workflows,omitempty"` + RepositoryHooks string `json:"repository-hooks,omitempty"` + SingleFile string `json:"single-file,omitempty"` + Codespaces string `json:"codespaces,omitempty"` + DependabotSecrets string `json:"dependabot-secrets,omitempty"` + RepositoryCustomProps string `json:"repository-custom-properties,omitempty"` + // Organization-level + Members string `json:"members,omitempty"` + OrganizationAdministration string `json:"organization-administration,omitempty"` + TeamDiscussions string `json:"team-discussions,omitempty"` + OrganizationHooks string `json:"organization-hooks,omitempty"` + OrganizationMembers string `json:"organization-members,omitempty"` + OrganizationPackages string `json:"organization-packages,omitempty"` + OrganizationSecrets string `json:"organization-secrets,omitempty"` + OrganizationSelfHostedRunners string `json:"organization-self-hosted-runners,omitempty"` + OrganizationCustomOrgRoles string `json:"organization-custom-org-roles,omitempty"` + OrganizationCustomProperties string `json:"organization-custom-properties,omitempty"` + OrganizationCustomRepositoryRoles string `json:"organization-custom-repository-roles,omitempty"` + OrganizationAnnouncementBanners string `json:"organization-announcement-banners,omitempty"` + OrganizationEvents string `json:"organization-events,omitempty"` + OrganizationPlan string `json:"organization-plan,omitempty"` + OrganizationUserBlocking string `json:"organization-user-blocking,omitempty"` + OrganizationPersonalAccessTokenReqs string `json:"organization-personal-access-token-requests,omitempty"` + OrganizationPersonalAccessTokens string `json:"organization-personal-access-tokens,omitempty"` + OrganizationCopilot string `json:"organization-copilot,omitempty"` + OrganizationCodespaces string `json:"organization-codespaces,omitempty"` + // User-level + EmailAddresses string `json:"email-addresses,omitempty"` + CodespacesLifecycleAdmin string `json:"codespaces-lifecycle-admin,omitempty"` + CodespacesMetadata string `json:"codespaces-metadata,omitempty"` + CodespacesSecrets string `json:"codespaces-secrets,omitempty"` } // PluginMCPConfig represents MCP configuration for a plugin @@ -356,6 +397,7 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err for scope, level := range permissions { if levelStr, ok := level.(string); ok { switch scope { + // GitHub Actions permission scopes case "actions": config.Actions = levelStr case "checks": @@ -384,8 +426,75 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err config.Statuses = levelStr case "organization-projects": config.OrganizationProjects = levelStr + // GitHub App-only permission scopes + case "administration": + config.Administration = levelStr + case "secrets": + config.Secrets = levelStr + case "environments": + config.Environments = levelStr + case "git-signing": + config.GitSigning = levelStr + case "vulnerability-alerts": + config.VulnerabilityAlerts = levelStr + case "workflows": + config.Workflows = levelStr + case "repository-hooks": + config.RepositoryHooks = levelStr + case "single-file": + config.SingleFile = levelStr + case "codespaces": + config.Codespaces = levelStr + case "dependabot-secrets": + config.DependabotSecrets = levelStr + case "repository-custom-properties": + config.RepositoryCustomProps = levelStr + case "members": + config.Members = levelStr + case "organization-administration": + config.OrganizationAdministration = levelStr + case "team-discussions": + config.TeamDiscussions = levelStr + case "organization-hooks": + config.OrganizationHooks = levelStr + case "organization-members": + config.OrganizationMembers = levelStr case "organization-packages": config.OrganizationPackages = levelStr + case "organization-secrets": + config.OrganizationSecrets = levelStr + case "organization-self-hosted-runners": + config.OrganizationSelfHostedRunners = levelStr + case "organization-custom-org-roles": + config.OrganizationCustomOrgRoles = levelStr + case "organization-custom-properties": + config.OrganizationCustomProperties = levelStr + case "organization-custom-repository-roles": + config.OrganizationCustomRepositoryRoles = levelStr + case "organization-announcement-banners": + config.OrganizationAnnouncementBanners = levelStr + case "organization-events": + config.OrganizationEvents = levelStr + case "organization-plan": + config.OrganizationPlan = levelStr + case "organization-user-blocking": + config.OrganizationUserBlocking = levelStr + case "organization-personal-access-token-requests": + config.OrganizationPersonalAccessTokenReqs = levelStr + case "organization-personal-access-tokens": + config.OrganizationPersonalAccessTokens = levelStr + case "organization-copilot": + config.OrganizationCopilot = levelStr + case "organization-codespaces": + config.OrganizationCodespaces = levelStr + case "email-addresses": + config.EmailAddresses = levelStr + case "codespaces-lifecycle-admin": + config.CodespacesLifecycleAdmin = levelStr + case "codespaces-metadata": + config.CodespacesMetadata = levelStr + case "codespaces-secrets": + config.CodespacesSecrets = levelStr } } } @@ -767,6 +876,7 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { result := make(map[string]any) + // GitHub Actions permission scopes if config.Actions != "" { result["actions"] = config.Actions } @@ -809,9 +919,114 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.OrganizationProjects != "" { result["organization-projects"] = config.OrganizationProjects } + + // GitHub App-only permission scopes - repository-level + if config.Administration != "" { + result["administration"] = config.Administration + } + if config.Secrets != "" { + result["secrets"] = config.Secrets + } + if config.Environments != "" { + result["environments"] = config.Environments + } + if config.GitSigning != "" { + result["git-signing"] = config.GitSigning + } + if config.VulnerabilityAlerts != "" { + result["vulnerability-alerts"] = config.VulnerabilityAlerts + } + if config.Workflows != "" { + result["workflows"] = config.Workflows + } + if config.RepositoryHooks != "" { + result["repository-hooks"] = config.RepositoryHooks + } + if config.SingleFile != "" { + result["single-file"] = config.SingleFile + } + if config.Codespaces != "" { + result["codespaces"] = config.Codespaces + } + if config.DependabotSecrets != "" { + result["dependabot-secrets"] = config.DependabotSecrets + } + if config.RepositoryCustomProps != "" { + result["repository-custom-properties"] = config.RepositoryCustomProps + } + + // GitHub App-only permission scopes - organization-level + if config.Members != "" { + result["members"] = config.Members + } + if config.OrganizationAdministration != "" { + result["organization-administration"] = config.OrganizationAdministration + } + if config.TeamDiscussions != "" { + result["team-discussions"] = config.TeamDiscussions + } + if config.OrganizationHooks != "" { + result["organization-hooks"] = config.OrganizationHooks + } + if config.OrganizationMembers != "" { + result["organization-members"] = config.OrganizationMembers + } if config.OrganizationPackages != "" { result["organization-packages"] = config.OrganizationPackages } + if config.OrganizationSecrets != "" { + result["organization-secrets"] = config.OrganizationSecrets + } + if config.OrganizationSelfHostedRunners != "" { + result["organization-self-hosted-runners"] = config.OrganizationSelfHostedRunners + } + if config.OrganizationCustomOrgRoles != "" { + result["organization-custom-org-roles"] = config.OrganizationCustomOrgRoles + } + if config.OrganizationCustomProperties != "" { + result["organization-custom-properties"] = config.OrganizationCustomProperties + } + if config.OrganizationCustomRepositoryRoles != "" { + result["organization-custom-repository-roles"] = config.OrganizationCustomRepositoryRoles + } + if config.OrganizationAnnouncementBanners != "" { + result["organization-announcement-banners"] = config.OrganizationAnnouncementBanners + } + if config.OrganizationEvents != "" { + result["organization-events"] = config.OrganizationEvents + } + if config.OrganizationPlan != "" { + result["organization-plan"] = config.OrganizationPlan + } + if config.OrganizationUserBlocking != "" { + result["organization-user-blocking"] = config.OrganizationUserBlocking + } + if config.OrganizationPersonalAccessTokenReqs != "" { + result["organization-personal-access-token-requests"] = config.OrganizationPersonalAccessTokenReqs + } + if config.OrganizationPersonalAccessTokens != "" { + result["organization-personal-access-tokens"] = config.OrganizationPersonalAccessTokens + } + if config.OrganizationCopilot != "" { + result["organization-copilot"] = config.OrganizationCopilot + } + if config.OrganizationCodespaces != "" { + result["organization-codespaces"] = config.OrganizationCodespaces + } + + // GitHub App-only permission scopes - user-level + if config.EmailAddresses != "" { + result["email-addresses"] = config.EmailAddresses + } + if config.CodespacesLifecycleAdmin != "" { + result["codespaces-lifecycle-admin"] = config.CodespacesLifecycleAdmin + } + if config.CodespacesMetadata != "" { + result["codespaces-metadata"] = config.CodespacesMetadata + } + if config.CodespacesSecrets != "" { + result["codespaces-secrets"] = config.CodespacesSecrets + } if len(result) == 0 { return nil diff --git a/pkg/workflow/github_app_permissions_validation.go b/pkg/workflow/github_app_permissions_validation.go new file mode 100644 index 00000000000..78d426a09a4 --- /dev/null +++ b/pkg/workflow/github_app_permissions_validation.go @@ -0,0 +1,119 @@ +package workflow + +import ( + "fmt" + "sort" + "strings" +) + +var githubAppPermissionsLog = newValidationLogger("github_app_permissions") + +// validateGitHubAppOnlyPermissions validates that when GitHub App-only permissions +// are specified in the workflow, a GitHub App is configured somewhere in the workflow. +// +// GitHub App-only permissions (e.g., members, administration, secrets) cannot be exercised +// through the GITHUB_TOKEN — they require a GitHub App installation access token. When such +// permissions are declared, a GitHub App must be configured via one of: +// - tools.github.github-app +// - safe-outputs.github-app +// - the top-level github-app field (for the activation/pre-activation jobs) +// +// Returns an error if GitHub App-only permissions are used without any GitHub App configured. +func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { + githubAppPermissionsLog.Print("Starting GitHub App-only permissions validation") + + if workflowData.Permissions == "" { + githubAppPermissionsLog.Print("No permissions defined, validation passed") + return nil + } + + permissions := NewPermissionsParser(workflowData.Permissions).ToPermissions() + if permissions == nil { + githubAppPermissionsLog.Print("Could not parse permissions, validation passed") + return nil + } + + // Find any GitHub App-only permission scopes that are set + var appOnlyScopes []PermissionScope + for _, scope := range GetAllGitHubAppOnlyScopes() { + if _, exists := permissions.Get(scope); exists { + appOnlyScopes = append(appOnlyScopes, scope) + } + } + + if len(appOnlyScopes) == 0 { + githubAppPermissionsLog.Print("No GitHub App-only permissions found, validation passed") + return nil + } + + githubAppPermissionsLog.Printf("Found %d GitHub App-only permissions, checking for GitHub App configuration", len(appOnlyScopes)) + + // Check if any GitHub App is configured + if hasGitHubAppConfigured(workflowData) { + githubAppPermissionsLog.Print("GitHub App is configured, validation passed") + return nil + } + + // Format the error message + return formatGitHubAppRequiredError(appOnlyScopes) +} + +// hasGitHubAppConfigured returns true if a GitHub App is configured anywhere in the workflow +func hasGitHubAppConfigured(workflowData *WorkflowData) bool { + // Check tools.github.github-app + if workflowData.ParsedTools != nil && + workflowData.ParsedTools.GitHub != nil && + workflowData.ParsedTools.GitHub.GitHubApp != nil { + githubAppPermissionsLog.Print("Found GitHub App in tools.github") + return true + } + + // Check safe-outputs.github-app + if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.GitHubApp != nil { + githubAppPermissionsLog.Print("Found GitHub App in safe-outputs") + return true + } + + // Check the activation job github-app + if workflowData.ActivationGitHubApp != nil { + githubAppPermissionsLog.Print("Found GitHub App in activation config") + return true + } + + return false +} + +// formatGitHubAppRequiredError formats an error message when GitHub App-only permissions +// are used without a GitHub App configured. +func formatGitHubAppRequiredError(appOnlyScopes []PermissionScope) error { + // Sort for deterministic output + scopeStrs := make([]string, len(appOnlyScopes)) + for i, s := range appOnlyScopes { + scopeStrs[i] = string(s) + } + sort.Strings(scopeStrs) + + var lines []string + lines = append(lines, "GitHub App-only permissions require a GitHub App to be configured.") + lines = append(lines, "The following permissions are not supported by the GITHUB_TOKEN and") + lines = append(lines, "can only be exercised through a GitHub App installation access token:") + lines = append(lines, "") + for _, s := range scopeStrs { + lines = append(lines, " - "+s) + } + lines = append(lines, "") + lines = append(lines, "To fix this, configure a GitHub App in your workflow. For example:") + lines = append(lines, ""+"tools:") + lines = append(lines, " github:") + lines = append(lines, " github-app:") + lines = append(lines, " app-id: ${{ vars.APP_ID }}") + lines = append(lines, " private-key: ${{ secrets.APP_PRIVATE_KEY }}") + lines = append(lines, "") + lines = append(lines, "Or in the safe-outputs section:") + lines = append(lines, "safe-outputs:") + lines = append(lines, " github-app:") + lines = append(lines, " app-id: ${{ vars.APP_ID }}") + lines = append(lines, " private-key: ${{ secrets.APP_PRIVATE_KEY }}") + + return fmt.Errorf("%s", strings.Join(lines, "\n")) +} diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go new file mode 100644 index 00000000000..469170146bc --- /dev/null +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -0,0 +1,430 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" +) + +func TestValidateGitHubAppOnlyPermissions(t *testing.T) { + tests := []struct { + name string + permissions string + parsedTools *ToolsConfig + safeOutputs *SafeOutputsConfig + activationApp *GitHubAppConfig + shouldError bool + errorContains string + }{ + { + name: "no permissions - should pass", + permissions: "", + shouldError: false, + }, + { + name: "GitHub Actions-only permissions - should pass without github-app", + permissions: "permissions:\n contents: read\n issues: write", + shouldError: false, + }, + { + name: "organization-projects (App-only) without github-app - should error", + permissions: "permissions:\n organization-projects: write", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "members permission without github-app - should error", + permissions: "permissions:\n members: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "administration permission without github-app - should error", + permissions: "permissions:\n administration: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "secrets permission without github-app - should error", + permissions: "permissions:\n secrets: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + { + name: "members permission with tools.github.github-app - should pass", + permissions: "permissions:\n members: read", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, + { + name: "members permission with safe-outputs.github-app - should pass", + permissions: "permissions:\n members: read", + safeOutputs: &SafeOutputsConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + shouldError: false, + }, + { + name: "members permission with activation github-app - should pass", + permissions: "permissions:\n members: read", + activationApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + shouldError: false, + }, + { + name: "organization-secrets permission without github-app - should error", + permissions: "permissions:\n organization-secrets: read", + shouldError: true, + errorContains: "organization-secrets", + }, + { + name: "workflows permission without github-app - should error", + permissions: "permissions:\n workflows: write", + shouldError: true, + errorContains: "workflows", + }, + { + name: "vulnerability-alerts permission without github-app - should error", + permissions: "permissions:\n vulnerability-alerts: read", + shouldError: true, + errorContains: "vulnerability-alerts", + }, + { + name: "mixed Actions and App-only permissions with github-app - should pass", + permissions: "permissions:\n contents: read\n members: read\n administration: read", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, + { + name: "mixed Actions and App-only permissions without github-app - should error", + permissions: "permissions:\n contents: read\n members: read", + shouldError: true, + errorContains: "GitHub App-only permissions require a GitHub App", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowData := &WorkflowData{ + Permissions: tt.permissions, + ParsedTools: tt.parsedTools, + SafeOutputs: tt.safeOutputs, + ActivationGitHubApp: tt.activationApp, + } + + err := validateGitHubAppOnlyPermissions(workflowData) + + if tt.shouldError { + if err == nil { + t.Errorf("Expected error but got none") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q, but got: %v", tt.errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + } + }) + } +} + +func TestIsGitHubAppOnlyScope(t *testing.T) { + tests := []struct { + scope PermissionScope + expected bool + }{ + // GitHub Actions scopes - should NOT be GitHub App-only + {PermissionActions, false}, + {PermissionChecks, false}, + {PermissionContents, false}, + {PermissionDeployments, false}, + {PermissionIssues, false}, + {PermissionPackages, false}, + {PermissionPages, false}, + {PermissionPullRequests, false}, + {PermissionSecurityEvents, false}, + {PermissionStatuses, false}, + {PermissionDiscussions, false}, + // organization-projects is a GitHub App-only scope (not in GitHub Actions GITHUB_TOKEN) + {PermissionOrganizationProj, true}, + // GitHub App-only scopes - should return true + {PermissionAdministration, true}, + {PermissionMembers, true}, + {PermissionOrganizationAdministration, true}, + {PermissionSecrets, true}, + {PermissionEnvironments, true}, + {PermissionGitSigning, true}, + {PermissionTeamDiscussions, true}, + {PermissionVulnerabilityAlerts, true}, + {PermissionWorkflows, true}, + {PermissionRepositoryHooks, true}, + {PermissionOrganizationHooks, true}, + {PermissionOrganizationMembers, true}, + {PermissionOrganizationPackages, true}, + {PermissionOrganizationSecrets, true}, + {PermissionOrganizationSelfHostedRunners, true}, + {PermissionSingleFile, true}, + {PermissionCodespaces, true}, + {PermissionDependabotSecrets, true}, + {PermissionEmailAddresses, true}, + } + + for _, tt := range tests { + t.Run(string(tt.scope), func(t *testing.T) { + result := IsGitHubAppOnlyScope(tt.scope) + if result != tt.expected { + t.Errorf("IsGitHubAppOnlyScope(%q) = %v, want %v", tt.scope, result, tt.expected) + } + }) + } +} + +func TestGetAllGitHubAppOnlyScopes(t *testing.T) { + scopes := GetAllGitHubAppOnlyScopes() + if len(scopes) == 0 { + t.Error("GetAllGitHubAppOnlyScopes should return at least one scope") + } + + // Verify some key scopes are included + keyScopes := []PermissionScope{ + PermissionAdministration, + PermissionMembers, + PermissionOrganizationAdministration, + PermissionSecrets, + PermissionEnvironments, + PermissionWorkflows, + PermissionVulnerabilityAlerts, + PermissionOrganizationSecrets, + PermissionOrganizationPackages, + } + + scopeSet := make(map[PermissionScope]bool) + for _, s := range scopes { + scopeSet[s] = true + } + + for _, expected := range keyScopes { + if !scopeSet[expected] { + t.Errorf("Expected scope %q to be in GetAllGitHubAppOnlyScopes()", expected) + } + } + + // Verify that GitHub Actions scopes are NOT included + actionScopes := []PermissionScope{ + PermissionContents, + PermissionIssues, + PermissionPullRequests, + PermissionChecks, + PermissionIdToken, + } + for _, notExpected := range actionScopes { + if scopeSet[notExpected] { + t.Errorf("Expected scope %q to NOT be in GetAllGitHubAppOnlyScopes()", notExpected) + } + } +} + +func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { + tests := []struct { + name string + permissions *Permissions + shouldContain []string + shouldSkip []string + }{ + { + name: "members permission is skipped in GitHub Actions YAML", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionMembers, PermissionRead) + return p + }(), + shouldSkip: []string{"members: read"}, + }, + { + name: "administration permission is skipped in GitHub Actions YAML", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + shouldSkip: []string{"administration: read"}, + }, + { + name: "contents permission IS included in GitHub Actions YAML", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionContents, PermissionRead) + return p + }(), + shouldContain: []string{"contents: read"}, + }, + { + name: "mixed permissions - only Actions scopes rendered", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionContents, PermissionRead) + p.Set(PermissionIssues, PermissionRead) + p.Set(PermissionMembers, PermissionRead) + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + shouldContain: []string{"contents: read", "issues: read"}, + shouldSkip: []string{"members: read", "administration: read"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + yaml := tt.permissions.RenderToYAML() + + for _, expected := range tt.shouldContain { + if !strings.Contains(yaml, expected) { + t.Errorf("Expected YAML to contain %q, but got:\n%s", expected, yaml) + } + } + + for _, notExpected := range tt.shouldSkip { + if strings.Contains(yaml, notExpected) { + t.Errorf("Expected YAML to NOT contain %q, but got:\n%s", notExpected, yaml) + } + } + }) + } +} + +func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { + tests := []struct { + name string + permissions *Permissions + expectedFields map[string]string + absentFields []string + }{ + { + name: "members permission maps to permission-members", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionMembers, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-members": "read", + }, + }, + { + name: "administration permission maps to permission-administration", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-administration": "read", + }, + }, + { + name: "organization-secrets permission maps correctly", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionOrganizationSecrets, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-organization-secrets": "read", + }, + }, + { + name: "workflows permission maps to permission-workflows", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionWorkflows, PermissionWrite) + return p + }(), + expectedFields: map[string]string{ + "permission-workflows": "write", + }, + }, + { + name: "vulnerability-alerts maps correctly", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionVulnerabilityAlerts, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-vulnerability-alerts": "read", + }, + }, + { + name: "models permission is NOT mapped (no GitHub App equivalent)", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionModels, PermissionRead) + return p + }(), + absentFields: []string{"permission-models"}, + }, + { + name: "id-token permission is NOT mapped (not applicable to GitHub Apps)", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionIdToken, PermissionWrite) + return p + }(), + absentFields: []string{"permission-id-token"}, + }, + { + name: "organization-packages permission maps correctly", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionOrganizationPackages, PermissionRead) + return p + }(), + expectedFields: map[string]string{ + "permission-organization-packages": "read", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := convertPermissionsToAppTokenFields(tt.permissions) + + for key, expectedValue := range tt.expectedFields { + if actualValue, exists := fields[key]; !exists { + t.Errorf("Expected field %q to be present, but it was not. Got fields: %v", key, fields) + } else if actualValue != expectedValue { + t.Errorf("Expected field %q = %q, got %q", key, expectedValue, actualValue) + } + } + + for _, absentKey := range tt.absentFields { + if _, exists := fields[absentKey]; exists { + t.Errorf("Expected field %q to be absent, but it was present with value %q", absentKey, fields[absentKey]) + } + } + }) + } +} diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 78dc40674f5..8c7d170a65d 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -1,6 +1,8 @@ package workflow import ( + "slices" + "github.com/github/gh-aw/pkg/logger" ) @@ -9,6 +11,7 @@ var permissionsLog = logger.New("workflow:permissions") // convertStringToPermissionScope converts a string key to a PermissionScope func convertStringToPermissionScope(key string) PermissionScope { switch key { + // GitHub Actions permission scopes (supported by GITHUB_TOKEN) case "actions": return PermissionActions case "attestations": @@ -45,6 +48,75 @@ func convertStringToPermissionScope(key string) PermissionScope { return PermissionStatuses case "copilot-requests": return PermissionCopilotRequests + // GitHub App-only permission scopes (not supported by GITHUB_TOKEN, require a GitHub App) + case "administration": + return PermissionAdministration + case "members": + return PermissionMembers + case "organization-administration": + return PermissionOrganizationAdministration + case "secrets": + return PermissionSecrets + case "environments": + return PermissionEnvironments + case "git-signing": + return PermissionGitSigning + case "team-discussions": + return PermissionTeamDiscussions + case "vulnerability-alerts": + return PermissionVulnerabilityAlerts + case "workflows": + return PermissionWorkflows + case "repository-hooks": + return PermissionRepositoryHooks + case "organization-hooks": + return PermissionOrganizationHooks + case "organization-members": + return PermissionOrganizationMembers + case "organization-packages": + return PermissionOrganizationPackages + case "organization-secrets": + return PermissionOrganizationSecrets + case "organization-self-hosted-runners": + return PermissionOrganizationSelfHostedRunners + case "single-file": + return PermissionSingleFile + case "codespaces": + return PermissionCodespaces + case "dependabot-secrets": + return PermissionDependabotSecrets + case "email-addresses": + return PermissionEmailAddresses + case "repository-custom-properties": + return PermissionRepositoryCustomProps + case "organization-custom-org-roles": + return PermissionOrganizationCustomOrgRoles + case "organization-custom-properties": + return PermissionOrganizationCustomProperties + case "organization-custom-repository-roles": + return PermissionOrganizationCustomRepositoryRoles + case "organization-announcement-banners": + return PermissionOrganizationAnnouncementBanners + case "organization-events": + return PermissionOrganizationEvents + case "organization-plan": + return PermissionOrganizationPlan + case "organization-user-blocking": + return PermissionOrganizationUserBlocking + case "organization-personal-access-token-requests": + return PermissionOrganizationPersonalAccessTokenReqs + case "organization-personal-access-tokens": + return PermissionOrganizationPersonalAccessTokens + case "organization-copilot": + return PermissionOrganizationCopilot + case "organization-codespaces": + return PermissionOrganizationCodespaces + case "codespaces-lifecycle-admin": + return PermissionCodespacesLifecycleAdmin + case "codespaces-metadata": + return PermissionCodespacesMetadata + case "codespaces-secrets": + return PermissionCodespacesSecrets case "all": // "all" is a meta-key handled at the parser level; it is not a real scope return "" @@ -67,6 +139,7 @@ const ( type PermissionScope string const ( + // GitHub Actions permission scopes (supported by GITHUB_TOKEN) PermissionActions PermissionScope = "actions" PermissionAttestations PermissionScope = "attestations" PermissionChecks PermissionScope = "checks" @@ -87,9 +160,56 @@ const ( // PermissionCopilotRequests is a GitHub Actions permission scope used with the copilot-requests feature. // It enables use of the GitHub Actions token as the Copilot authentication token. PermissionCopilotRequests PermissionScope = "copilot-requests" + + // GitHub App-only permission scopes (not supported by GITHUB_TOKEN, require a GitHub App token). + // When any of these are specified in the workflow permissions, a GitHub App must be configured. + // These permissions are skipped when rendering GitHub Actions workflow YAML, but are passed + // as permission-* inputs when minting GitHub App installation access tokens. + + // Repository-level GitHub App permissions + PermissionAdministration PermissionScope = "administration" + PermissionSecrets PermissionScope = "secrets" + PermissionEnvironments PermissionScope = "environments" + PermissionGitSigning PermissionScope = "git-signing" + PermissionVulnerabilityAlerts PermissionScope = "vulnerability-alerts" + PermissionWorkflows PermissionScope = "workflows" + PermissionRepositoryHooks PermissionScope = "repository-hooks" + PermissionSingleFile PermissionScope = "single-file" + PermissionCodespaces PermissionScope = "codespaces" + PermissionDependabotSecrets PermissionScope = "dependabot-secrets" + PermissionRepositoryCustomProps PermissionScope = "repository-custom-properties" + + // Organization-level GitHub App permissions + PermissionMembers PermissionScope = "members" + PermissionOrganizationAdministration PermissionScope = "organization-administration" + PermissionTeamDiscussions PermissionScope = "team-discussions" + PermissionOrganizationHooks PermissionScope = "organization-hooks" + PermissionOrganizationMembers PermissionScope = "organization-members" + PermissionOrganizationPackages PermissionScope = "organization-packages" + PermissionOrganizationSecrets PermissionScope = "organization-secrets" + PermissionOrganizationSelfHostedRunners PermissionScope = "organization-self-hosted-runners" + PermissionOrganizationCustomOrgRoles PermissionScope = "organization-custom-org-roles" + PermissionOrganizationCustomProperties PermissionScope = "organization-custom-properties" + PermissionOrganizationCustomRepositoryRoles PermissionScope = "organization-custom-repository-roles" + PermissionOrganizationAnnouncementBanners PermissionScope = "organization-announcement-banners" + PermissionOrganizationEvents PermissionScope = "organization-events" + PermissionOrganizationPlan PermissionScope = "organization-plan" + PermissionOrganizationUserBlocking PermissionScope = "organization-user-blocking" + PermissionOrganizationPersonalAccessTokenReqs PermissionScope = "organization-personal-access-token-requests" + PermissionOrganizationPersonalAccessTokens PermissionScope = "organization-personal-access-tokens" + PermissionOrganizationCopilot PermissionScope = "organization-copilot" + PermissionOrganizationCodespaces PermissionScope = "organization-codespaces" + + // User-level GitHub App permissions + PermissionEmailAddresses PermissionScope = "email-addresses" + PermissionCodespacesLifecycleAdmin PermissionScope = "codespaces-lifecycle-admin" + PermissionCodespacesMetadata PermissionScope = "codespaces-metadata" + PermissionCodespacesSecrets PermissionScope = "codespaces-secrets" ) -// GetAllPermissionScopes returns all available permission scopes +// GetAllPermissionScopes returns all GitHub Actions permission scopes (supported by GITHUB_TOKEN). +// These are the scopes that can be set on the workflow's GITHUB_TOKEN. +// For GitHub App-only scopes, see GetAllGitHubAppOnlyScopes. func GetAllPermissionScopes() []PermissionScope { return []PermissionScope{ PermissionActions, @@ -106,12 +226,63 @@ func GetAllPermissionScopes() []PermissionScope { PermissionPages, PermissionPullRequests, PermissionRepositoryProj, - PermissionOrganizationProj, PermissionSecurityEvents, PermissionStatuses, } } +// GetAllGitHubAppOnlyScopes returns all GitHub App-only permission scopes. +// These scopes are not supported by GITHUB_TOKEN and require a GitHub App installation token. +// When any of these scopes are used in a workflow, a GitHub App must be configured. +func GetAllGitHubAppOnlyScopes() []PermissionScope { + return []PermissionScope{ + // Repository-level GitHub App permissions + PermissionAdministration, + PermissionSecrets, + PermissionEnvironments, + PermissionGitSigning, + PermissionVulnerabilityAlerts, + PermissionWorkflows, + PermissionRepositoryHooks, + PermissionSingleFile, + PermissionCodespaces, + PermissionDependabotSecrets, + PermissionRepositoryCustomProps, + // Organization-level GitHub App permissions + PermissionOrganizationProj, + PermissionMembers, + PermissionOrganizationAdministration, + PermissionTeamDiscussions, + PermissionOrganizationHooks, + PermissionOrganizationMembers, + PermissionOrganizationPackages, + PermissionOrganizationSecrets, + PermissionOrganizationSelfHostedRunners, + PermissionOrganizationCustomOrgRoles, + PermissionOrganizationCustomProperties, + PermissionOrganizationCustomRepositoryRoles, + PermissionOrganizationAnnouncementBanners, + PermissionOrganizationEvents, + PermissionOrganizationPlan, + PermissionOrganizationUserBlocking, + PermissionOrganizationPersonalAccessTokenReqs, + PermissionOrganizationPersonalAccessTokens, + PermissionOrganizationCopilot, + PermissionOrganizationCodespaces, + // User-level GitHub App permissions + PermissionEmailAddresses, + PermissionCodespacesLifecycleAdmin, + PermissionCodespacesMetadata, + PermissionCodespacesSecrets, + } +} + +// IsGitHubAppOnlyScope returns true if the scope is a GitHub App-only permission +// (not supported by GITHUB_TOKEN). These scopes require a GitHub App to exercise. +func IsGitHubAppOnlyScope(scope PermissionScope) bool { + return slices.Contains(GetAllGitHubAppOnlyScopes(), scope) +} + // Permissions represents GitHub Actions permissions // It can be a shorthand (read-all, write-all, read, write, none) or a map of scopes to levels // It can also have an "all" permission that expands to all scopes diff --git a/pkg/workflow/permissions_operations.go b/pkg/workflow/permissions_operations.go index dfc54df035b..b0004b04f1a 100644 --- a/pkg/workflow/permissions_operations.go +++ b/pkg/workflow/permissions_operations.go @@ -257,8 +257,10 @@ func (p *Permissions) RenderToYAML() string { scope := PermissionScope(scopeStr) level := allPerms[scope] - // Skip organization-projects - it's only valid for GitHub App tokens, not workflow permissions - if scope == PermissionOrganizationProj { + // Skip GitHub App-only permissions - they are not valid GitHub Actions workflow permissions + // and cannot be set on the GITHUB_TOKEN. They are handled separately when minting + // GitHub App installation access tokens. + if IsGitHubAppOnlyScope(scope) { continue } diff --git a/pkg/workflow/permissions_operations_test.go b/pkg/workflow/permissions_operations_test.go index 9874d1bad20..05518baac88 100644 --- a/pkg/workflow/permissions_operations_test.go +++ b/pkg/workflow/permissions_operations_test.go @@ -350,23 +350,23 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionContents: PermissionWrite}), merge: NewPermissionsReadAll(), want: map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionWrite, // preserved - PermissionActions: PermissionRead, // added - PermissionAttestations: PermissionRead, - PermissionChecks: PermissionRead, - PermissionDeployments: PermissionRead, - PermissionDiscussions: PermissionRead, - PermissionIssues: PermissionRead, - PermissionMetadata: PermissionRead, - PermissionPackages: PermissionRead, - PermissionPages: PermissionRead, - PermissionPullRequests: PermissionRead, - PermissionRepositoryProj: PermissionRead, - PermissionOrganizationProj: PermissionRead, - PermissionSecurityEvents: PermissionRead, - PermissionStatuses: PermissionRead, - PermissionModels: PermissionRead, + PermissionContents: PermissionWrite, // preserved + PermissionActions: PermissionRead, // added + PermissionAttestations: PermissionRead, + PermissionChecks: PermissionRead, + PermissionDeployments: PermissionRead, + PermissionDiscussions: PermissionRead, + PermissionIssues: PermissionRead, + PermissionMetadata: PermissionRead, + PermissionPackages: PermissionRead, + PermissionPages: PermissionRead, + PermissionPullRequests: PermissionRead, + PermissionRepositoryProj: PermissionRead, + PermissionSecurityEvents: PermissionRead, + PermissionStatuses: PermissionRead, + PermissionModels: PermissionRead, // Note: id-token is NOT included because it doesn't support read level + // Note: organization-projects is NOT included because it's a GitHub App-only scope }, }, { @@ -374,23 +374,23 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionContents: PermissionRead}), merge: NewPermissionsWriteAll(), want: map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionRead, // preserved (not overwritten) - PermissionActions: PermissionWrite, - PermissionAttestations: PermissionWrite, - PermissionChecks: PermissionWrite, - PermissionDeployments: PermissionWrite, - PermissionDiscussions: PermissionWrite, - PermissionIdToken: PermissionWrite, // id-token supports write - PermissionIssues: PermissionWrite, - PermissionMetadata: PermissionWrite, - PermissionPackages: PermissionWrite, - PermissionPages: PermissionWrite, - PermissionPullRequests: PermissionWrite, - PermissionRepositoryProj: PermissionWrite, - PermissionOrganizationProj: PermissionWrite, - PermissionSecurityEvents: PermissionWrite, - PermissionStatuses: PermissionWrite, - PermissionModels: PermissionWrite, + PermissionContents: PermissionRead, // preserved (not overwritten) + PermissionActions: PermissionWrite, + PermissionAttestations: PermissionWrite, + PermissionChecks: PermissionWrite, + PermissionDeployments: PermissionWrite, + PermissionDiscussions: PermissionWrite, + PermissionIdToken: PermissionWrite, // id-token supports write + PermissionIssues: PermissionWrite, + PermissionMetadata: PermissionWrite, + PermissionPackages: PermissionWrite, + PermissionPages: PermissionWrite, + PermissionPullRequests: PermissionWrite, + PermissionRepositoryProj: PermissionWrite, + PermissionSecurityEvents: PermissionWrite, + PermissionStatuses: PermissionWrite, + PermissionModels: PermissionWrite, + // Note: organization-projects is NOT included because it's a GitHub App-only scope }, }, { @@ -398,23 +398,23 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionContents: PermissionWrite}), merge: NewPermissionsReadAll(), want: map[PermissionScope]PermissionLevel{ - PermissionContents: PermissionWrite, - PermissionActions: PermissionRead, - PermissionAttestations: PermissionRead, - PermissionChecks: PermissionRead, - PermissionDeployments: PermissionRead, - PermissionDiscussions: PermissionRead, - PermissionIssues: PermissionRead, - PermissionMetadata: PermissionRead, - PermissionPackages: PermissionRead, - PermissionPages: PermissionRead, - PermissionPullRequests: PermissionRead, - PermissionRepositoryProj: PermissionRead, - PermissionOrganizationProj: PermissionRead, - PermissionSecurityEvents: PermissionRead, - PermissionStatuses: PermissionRead, - PermissionModels: PermissionRead, + PermissionContents: PermissionWrite, + PermissionActions: PermissionRead, + PermissionAttestations: PermissionRead, + PermissionChecks: PermissionRead, + PermissionDeployments: PermissionRead, + PermissionDiscussions: PermissionRead, + PermissionIssues: PermissionRead, + PermissionMetadata: PermissionRead, + PermissionPackages: PermissionRead, + PermissionPages: PermissionRead, + PermissionPullRequests: PermissionRead, + PermissionRepositoryProj: PermissionRead, + PermissionSecurityEvents: PermissionRead, + PermissionStatuses: PermissionRead, + PermissionModels: PermissionRead, // Note: id-token is NOT included because it doesn't support read level + // Note: organization-projects is NOT included because it's a GitHub App-only scope }, }, { @@ -422,23 +422,22 @@ func TestPermissionsMerge(t *testing.T) { base: NewPermissionsFromMap(map[PermissionScope]PermissionLevel{PermissionIssues: PermissionRead}), merge: NewPermissionsWriteAll(), want: map[PermissionScope]PermissionLevel{ - PermissionIssues: PermissionRead, - PermissionActions: PermissionWrite, - PermissionAttestations: PermissionWrite, - PermissionChecks: PermissionWrite, - PermissionContents: PermissionWrite, - PermissionDeployments: PermissionWrite, - PermissionDiscussions: PermissionWrite, - PermissionIdToken: PermissionWrite, // id-token supports write - PermissionMetadata: PermissionWrite, - PermissionPackages: PermissionWrite, - PermissionPages: PermissionWrite, - PermissionPullRequests: PermissionWrite, - PermissionRepositoryProj: PermissionWrite, - PermissionOrganizationProj: PermissionWrite, - PermissionSecurityEvents: PermissionWrite, - PermissionStatuses: PermissionWrite, - PermissionModels: PermissionWrite, + PermissionIssues: PermissionRead, + PermissionActions: PermissionWrite, + PermissionAttestations: PermissionWrite, + PermissionChecks: PermissionWrite, + PermissionContents: PermissionWrite, + PermissionDeployments: PermissionWrite, + PermissionDiscussions: PermissionWrite, + PermissionIdToken: PermissionWrite, // id-token supports write + PermissionMetadata: PermissionWrite, + PermissionPackages: PermissionWrite, + PermissionPages: PermissionWrite, + PermissionPullRequests: PermissionWrite, + PermissionRepositoryProj: PermissionWrite, + PermissionSecurityEvents: PermissionWrite, + PermissionStatuses: PermissionWrite, + PermissionModels: PermissionWrite, }, }, { diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 828b32d1ff9..447f71c17e8 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -197,17 +197,17 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions // convertPermissionsToAppTokenFields converts job Permissions to permission-* action inputs // This follows GitHub's recommendation for explicit permission control -// Note: This only includes permissions that are valid for GitHub App tokens. -// Some GitHub Actions permissions (like 'discussions', 'models') don't have -// corresponding GitHub App permissions and are skipped. +// Note: This maps all permissions (both GitHub Actions and GitHub App-only) to their +// corresponding permission-* fields in actions/create-github-app-token. +// Some GitHub Actions permissions (like 'models', 'id-token', 'attestations', 'copilot-requests') +// don't have corresponding GitHub App permissions and are skipped. func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]string { fields := make(map[string]string) // Map GitHub Actions permissions to GitHub App permissions - // Only include permissions that exist in the actions/create-github-app-token action // See: https://github.com/actions/create-github-app-token#permissions - // Repository permissions that map directly + // GitHub Actions permissions that also exist in GitHub App if level, ok := permissions.Get(PermissionActions); ok { fields["permission-actions"] = string(level) } @@ -245,11 +245,120 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str fields["permission-discussions"] = string(level) } - // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents: - // - models (no GitHub App permission for this) - // - id-token (not applicable to GitHub Apps) - // - attestations (no GitHub App permission for this) - // - repository-projects (removed - classic projects are sunset; use organization-projects for Projects v2 via PAT/GitHub App) + // GitHub App-only permissions (not available in GitHub Actions GITHUB_TOKEN) + // Repository-level + if level, ok := permissions.Get(PermissionAdministration); ok { + fields["permission-administration"] = string(level) + } + if level, ok := permissions.Get(PermissionSecrets); ok { + fields["permission-secrets"] = string(level) + } + if level, ok := permissions.Get(PermissionEnvironments); ok { + fields["permission-environments"] = string(level) + } + if level, ok := permissions.Get(PermissionGitSigning); ok { + fields["permission-git-signing"] = string(level) + } + if level, ok := permissions.Get(PermissionVulnerabilityAlerts); ok { + fields["permission-vulnerability-alerts"] = string(level) + } + if level, ok := permissions.Get(PermissionWorkflows); ok { + fields["permission-workflows"] = string(level) + } + if level, ok := permissions.Get(PermissionRepositoryHooks); ok { + fields["permission-repository-hooks"] = string(level) + } + if level, ok := permissions.Get(PermissionSingleFile); ok { + fields["permission-single-file"] = string(level) + } + if level, ok := permissions.Get(PermissionCodespaces); ok { + fields["permission-codespaces"] = string(level) + } + if level, ok := permissions.Get(PermissionDependabotSecrets); ok { + fields["permission-dependabot-secrets"] = string(level) + } + if level, ok := permissions.Get(PermissionRepositoryCustomProps); ok { + fields["permission-repository-custom-properties"] = string(level) + } + // Organization-level + if level, ok := permissions.Get(PermissionMembers); ok { + fields["permission-members"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationAdministration); ok { + fields["permission-organization-administration"] = string(level) + } + if level, ok := permissions.Get(PermissionTeamDiscussions); ok { + fields["permission-team-discussions"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationHooks); ok { + fields["permission-organization-hooks"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationMembers); ok { + fields["permission-organization-members"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationPackages); ok { + fields["permission-organization-packages"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationSecrets); ok { + fields["permission-organization-secrets"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationSelfHostedRunners); ok { + fields["permission-organization-self-hosted-runners"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationCustomOrgRoles); ok { + fields["permission-organization-custom-org-roles"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationCustomProperties); ok { + fields["permission-organization-custom-properties"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationCustomRepositoryRoles); ok { + fields["permission-organization-custom-repository-roles"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationAnnouncementBanners); ok { + fields["permission-organization-announcement-banners"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationEvents); ok { + fields["permission-organization-events"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationPlan); ok { + fields["permission-organization-plan"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationUserBlocking); ok { + fields["permission-organization-user-blocking"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationPersonalAccessTokenReqs); ok { + fields["permission-organization-personal-access-token-requests"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationPersonalAccessTokens); ok { + fields["permission-organization-personal-access-tokens"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationCopilot); ok { + fields["permission-organization-copilot"] = string(level) + } + if level, ok := permissions.Get(PermissionOrganizationCodespaces); ok { + fields["permission-organization-codespaces"] = string(level) + } + // User-level + if level, ok := permissions.Get(PermissionEmailAddresses); ok { + fields["permission-email-addresses"] = string(level) + } + if level, ok := permissions.Get(PermissionCodespacesLifecycleAdmin); ok { + fields["permission-codespaces-lifecycle-admin"] = string(level) + } + if level, ok := permissions.Get(PermissionCodespacesMetadata); ok { + fields["permission-codespaces-metadata"] = string(level) + } + if level, ok := permissions.Get(PermissionCodespacesSecrets); ok { + fields["permission-codespaces-secrets"] = string(level) + } + + // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents + // and are therefore not mapped to permission-* fields: + // - models: no GitHub App permission for AI model access + // - id-token: not applicable to GitHub Apps (OIDC-specific) + // - attestations: no GitHub App permission for this + // - copilot-requests: GitHub Actions-specific Copilot authentication token + // - metadata: GitHub App metadata permission is automatically included (read-only) return fields } From 1867cfd391cd22aceffec4cc1930998e5f3970e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:17:40 +0000 Subject: [PATCH 03/11] refactor: rename PermissionRepositoryCustomProps to PermissionRepositoryCustomProperties Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/frontmatter_types.go | 28 ++++++++++++------------- pkg/workflow/permissions.go | 26 +++++++++++------------ pkg/workflow/safe_outputs_app_config.go | 2 +- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 44d66e355ed..e4f879eb9e6 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -62,17 +62,17 @@ type PermissionsConfig struct { // GitHub App-only permission scopes (not supported by GITHUB_TOKEN). // When any of these are specified, a GitHub App must be configured in the workflow. // Repository-level - Administration string `json:"administration,omitempty"` - Secrets string `json:"secrets,omitempty"` - Environments string `json:"environments,omitempty"` - GitSigning string `json:"git-signing,omitempty"` - VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` - Workflows string `json:"workflows,omitempty"` - RepositoryHooks string `json:"repository-hooks,omitempty"` - SingleFile string `json:"single-file,omitempty"` - Codespaces string `json:"codespaces,omitempty"` - DependabotSecrets string `json:"dependabot-secrets,omitempty"` - RepositoryCustomProps string `json:"repository-custom-properties,omitempty"` + Administration string `json:"administration,omitempty"` + Secrets string `json:"secrets,omitempty"` + Environments string `json:"environments,omitempty"` + GitSigning string `json:"git-signing,omitempty"` + VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` + Workflows string `json:"workflows,omitempty"` + RepositoryHooks string `json:"repository-hooks,omitempty"` + SingleFile string `json:"single-file,omitempty"` + Codespaces string `json:"codespaces,omitempty"` + DependabotSecrets string `json:"dependabot-secrets,omitempty"` + RepositoryCustomProperties string `json:"repository-custom-properties,omitempty"` // Organization-level Members string `json:"members,omitempty"` OrganizationAdministration string `json:"organization-administration,omitempty"` @@ -448,7 +448,7 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err case "dependabot-secrets": config.DependabotSecrets = levelStr case "repository-custom-properties": - config.RepositoryCustomProps = levelStr + config.RepositoryCustomProperties = levelStr case "members": config.Members = levelStr case "organization-administration": @@ -951,8 +951,8 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.DependabotSecrets != "" { result["dependabot-secrets"] = config.DependabotSecrets } - if config.RepositoryCustomProps != "" { - result["repository-custom-properties"] = config.RepositoryCustomProps + if config.RepositoryCustomProperties != "" { + result["repository-custom-properties"] = config.RepositoryCustomProperties } // GitHub App-only permission scopes - organization-level diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 8c7d170a65d..136d2e7400c 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -88,7 +88,7 @@ func convertStringToPermissionScope(key string) PermissionScope { case "email-addresses": return PermissionEmailAddresses case "repository-custom-properties": - return PermissionRepositoryCustomProps + return PermissionRepositoryCustomProperties case "organization-custom-org-roles": return PermissionOrganizationCustomOrgRoles case "organization-custom-properties": @@ -167,17 +167,17 @@ const ( // as permission-* inputs when minting GitHub App installation access tokens. // Repository-level GitHub App permissions - PermissionAdministration PermissionScope = "administration" - PermissionSecrets PermissionScope = "secrets" - PermissionEnvironments PermissionScope = "environments" - PermissionGitSigning PermissionScope = "git-signing" - PermissionVulnerabilityAlerts PermissionScope = "vulnerability-alerts" - PermissionWorkflows PermissionScope = "workflows" - PermissionRepositoryHooks PermissionScope = "repository-hooks" - PermissionSingleFile PermissionScope = "single-file" - PermissionCodespaces PermissionScope = "codespaces" - PermissionDependabotSecrets PermissionScope = "dependabot-secrets" - PermissionRepositoryCustomProps PermissionScope = "repository-custom-properties" + PermissionAdministration PermissionScope = "administration" + PermissionSecrets PermissionScope = "secrets" + PermissionEnvironments PermissionScope = "environments" + PermissionGitSigning PermissionScope = "git-signing" + PermissionVulnerabilityAlerts PermissionScope = "vulnerability-alerts" + PermissionWorkflows PermissionScope = "workflows" + PermissionRepositoryHooks PermissionScope = "repository-hooks" + PermissionSingleFile PermissionScope = "single-file" + PermissionCodespaces PermissionScope = "codespaces" + PermissionDependabotSecrets PermissionScope = "dependabot-secrets" + PermissionRepositoryCustomProperties PermissionScope = "repository-custom-properties" // Organization-level GitHub App permissions PermissionMembers PermissionScope = "members" @@ -247,7 +247,7 @@ func GetAllGitHubAppOnlyScopes() []PermissionScope { PermissionSingleFile, PermissionCodespaces, PermissionDependabotSecrets, - PermissionRepositoryCustomProps, + PermissionRepositoryCustomProperties, // Organization-level GitHub App permissions PermissionOrganizationProj, PermissionMembers, diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 447f71c17e8..5f2034bc591 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -277,7 +277,7 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.Get(PermissionDependabotSecrets); ok { fields["permission-dependabot-secrets"] = string(level) } - if level, ok := permissions.Get(PermissionRepositoryCustomProps); ok { + if level, ok := permissions.Get(PermissionRepositoryCustomProperties); ok { fields["permission-repository-custom-properties"] = string(level) } // Organization-level From 7fc28c618e1a2cd81ab72593c69e0145c60c3146 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:43:44 +0000 Subject: [PATCH 04/11] refactor: split PermissionsConfig into GitHubActionsPermissionsConfig and GitHubAppPermissionsConfig Respond to code review feedback: use two separate embedded types to clearly separate GitHub Actions GITHUB_TOKEN scopes from GitHub App-only scopes. PermissionsConfig now embeds both, preserving all existing field access patterns. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/frontmatter_types.go | 91 ++++++++++++++++++------------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index e4f879eb9e6..b4fb2f3f1ee 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -35,45 +35,31 @@ type RuntimesConfig struct { Ruby *RuntimeConfig `json:"ruby,omitempty"` // Ruby runtime } -// PermissionsConfig represents GitHub Actions permissions configuration -// Supports both shorthand (read-all, write-all) and detailed scope-based permissions. -// In addition to standard GitHub Actions scopes, this also supports GitHub App-only scopes -// (e.g., members, administration) for use when a GitHub App is configured. -type PermissionsConfig struct { - // Shorthand permission (read-all, write-all, read, write, none) - Shorthand string `json:"-"` // Not in JSON, set when parsing shorthand format +// GitHubActionsPermissionsConfig holds permission scopes supported by the GitHub Actions GITHUB_TOKEN. +// These scopes can be declared in the workflow's top-level permissions block and are enforced +// natively by GitHub Actions. +type GitHubActionsPermissionsConfig struct { + Actions string `json:"actions,omitempty"` + Checks string `json:"checks,omitempty"` + Contents string `json:"contents,omitempty"` + Deployments string `json:"deployments,omitempty"` + IDToken string `json:"id-token,omitempty"` + Issues string `json:"issues,omitempty"` + Discussions string `json:"discussions,omitempty"` + Packages string `json:"packages,omitempty"` + Pages string `json:"pages,omitempty"` + PullRequests string `json:"pull-requests,omitempty"` + RepositoryProjects string `json:"repository-projects,omitempty"` + SecurityEvents string `json:"security-events,omitempty"` + Statuses string `json:"statuses,omitempty"` +} - // Detailed permissions by scope - GitHub Actions scopes (supported by GITHUB_TOKEN) - Actions string `json:"actions,omitempty"` - Checks string `json:"checks,omitempty"` - Contents string `json:"contents,omitempty"` - Deployments string `json:"deployments,omitempty"` - IDToken string `json:"id-token,omitempty"` - Issues string `json:"issues,omitempty"` - Discussions string `json:"discussions,omitempty"` - Packages string `json:"packages,omitempty"` - Pages string `json:"pages,omitempty"` - PullRequests string `json:"pull-requests,omitempty"` - RepositoryProjects string `json:"repository-projects,omitempty"` - SecurityEvents string `json:"security-events,omitempty"` - Statuses string `json:"statuses,omitempty"` - OrganizationProjects string `json:"organization-projects,omitempty"` - - // GitHub App-only permission scopes (not supported by GITHUB_TOKEN). - // When any of these are specified, a GitHub App must be configured in the workflow. - // Repository-level - Administration string `json:"administration,omitempty"` - Secrets string `json:"secrets,omitempty"` - Environments string `json:"environments,omitempty"` - GitSigning string `json:"git-signing,omitempty"` - VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` - Workflows string `json:"workflows,omitempty"` - RepositoryHooks string `json:"repository-hooks,omitempty"` - SingleFile string `json:"single-file,omitempty"` - Codespaces string `json:"codespaces,omitempty"` - DependabotSecrets string `json:"dependabot-secrets,omitempty"` - RepositoryCustomProperties string `json:"repository-custom-properties,omitempty"` - // Organization-level +// GitHubAppPermissionsConfig holds permission scopes that are exclusive to GitHub App +// installation access tokens (not supported by GITHUB_TOKEN). When any of these are +// specified, a GitHub App must be configured in the workflow. +type GitHubAppPermissionsConfig struct { + // Organization-level permissions (the common use-case placed first) + OrganizationProjects string `json:"organization-projects,omitempty"` Members string `json:"members,omitempty"` OrganizationAdministration string `json:"organization-administration,omitempty"` TeamDiscussions string `json:"team-discussions,omitempty"` @@ -93,13 +79,40 @@ type PermissionsConfig struct { OrganizationPersonalAccessTokens string `json:"organization-personal-access-tokens,omitempty"` OrganizationCopilot string `json:"organization-copilot,omitempty"` OrganizationCodespaces string `json:"organization-codespaces,omitempty"` - // User-level + // Repository-level permissions + Administration string `json:"administration,omitempty"` + Secrets string `json:"secrets,omitempty"` + Environments string `json:"environments,omitempty"` + GitSigning string `json:"git-signing,omitempty"` + VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` + Workflows string `json:"workflows,omitempty"` + RepositoryHooks string `json:"repository-hooks,omitempty"` + SingleFile string `json:"single-file,omitempty"` + Codespaces string `json:"codespaces,omitempty"` + DependabotSecrets string `json:"dependabot-secrets,omitempty"` + RepositoryCustomProperties string `json:"repository-custom-properties,omitempty"` + // User-level permissions EmailAddresses string `json:"email-addresses,omitempty"` CodespacesLifecycleAdmin string `json:"codespaces-lifecycle-admin,omitempty"` CodespacesMetadata string `json:"codespaces-metadata,omitempty"` CodespacesSecrets string `json:"codespaces-secrets,omitempty"` } +// PermissionsConfig represents GitHub Actions permissions configuration. +// Supports both shorthand (read-all, write-all) and detailed scope-based permissions. +// Embeds GitHubActionsPermissionsConfig for standard GITHUB_TOKEN scopes and +// GitHubAppPermissionsConfig for GitHub App-only scopes. +type PermissionsConfig struct { + // Shorthand permission (read-all, write-all, read, write, none) + Shorthand string `json:"-"` // Not in JSON, set when parsing shorthand format + + // GitHub Actions GITHUB_TOKEN permission scopes + GitHubActionsPermissionsConfig + + // GitHub App-only permission scopes (require a GitHub App to be configured) + GitHubAppPermissionsConfig +} + // PluginMCPConfig represents MCP configuration for a plugin type PluginMCPConfig struct { Env map[string]string `json:"env,omitempty"` // Environment variables for MCP server instantiation From 160e20cb7ac60f6bc69da80ce17d2cc17b6a2df3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:59:51 +0000 Subject: [PATCH 05/11] Changes before error encountered Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../github_app_permissions_validation.go | 7 +- pkg/workflow/permissions_operations.go | 13 +++ pkg/workflow/safe_outputs_app_config.go | 84 ++++++++++--------- 3 files changed, 64 insertions(+), 40 deletions(-) diff --git a/pkg/workflow/github_app_permissions_validation.go b/pkg/workflow/github_app_permissions_validation.go index 78d426a09a4..0897d4c196c 100644 --- a/pkg/workflow/github_app_permissions_validation.go +++ b/pkg/workflow/github_app_permissions_validation.go @@ -33,10 +33,13 @@ func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { return nil } - // Find any GitHub App-only permission scopes that are set + // Find any GitHub App-only permission scopes that are *explicitly* declared. + // We must not use Get() here because shorthand permissions (read-all / write-all) and + // "all: read" would cause Get() to return a value for every scope, incorrectly + // requiring a GitHub App even when no App-only scopes were explicitly declared. var appOnlyScopes []PermissionScope for _, scope := range GetAllGitHubAppOnlyScopes() { - if _, exists := permissions.Get(scope); exists { + if _, exists := permissions.GetExplicit(scope); exists { appOnlyScopes = append(appOnlyScopes, scope) } } diff --git a/pkg/workflow/permissions_operations.go b/pkg/workflow/permissions_operations.go index b0004b04f1a..b4b12233ad5 100644 --- a/pkg/workflow/permissions_operations.go +++ b/pkg/workflow/permissions_operations.go @@ -47,6 +47,19 @@ func (p *Permissions) Set(scope PermissionScope, level PermissionLevel) { p.permissions[scope] = level } +// GetExplicit returns the permission level only if the scope was explicitly declared in the +// permissions map. Unlike Get, it never returns a level derived from shorthand (read-all / +// write-all) or "all: read" defaults. Use this when you need to know what the user explicitly +// specified — for example, when deciding which GitHub App-only scopes to forward to +// actions/create-github-app-token, or when validating that App-only scopes are present. +func (p *Permissions) GetExplicit(scope PermissionScope) (PermissionLevel, bool) { + if p == nil { + return "", false + } + level, exists := p.permissions[scope] + return level, exists +} + // Get gets the permission level for a specific scope func (p *Permissions) Get(scope PermissionScope) (PermissionLevel, bool) { if p.shorthand != "" { diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 5f2034bc591..02eaff853f0 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -201,6 +201,12 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions // corresponding permission-* fields in actions/create-github-app-token. // Some GitHub Actions permissions (like 'models', 'id-token', 'attestations', 'copilot-requests') // don't have corresponding GitHub App permissions and are skipped. +// +// For GitHub Actions permissions (actions, checks, contents, …) we use Get() so that shorthand +// permissions like "read-all" are correctly expanded. +// For GitHub App-only permissions (administration, members, organization-secrets, …) we use +// GetExplicit() so that only scopes the user actually declared are forwarded — a "read-all" +// shorthand must never accidentally grant broad GitHub App-only permissions. func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]string { fields := make(map[string]string) @@ -238,117 +244,119 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.Get(PermissionStatuses); ok { fields["permission-statuses"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationProj); ok { - fields["permission-organization-projects"] = string(level) - } if level, ok := permissions.Get(PermissionDiscussions); ok { fields["permission-discussions"] = string(level) } - // GitHub App-only permissions (not available in GitHub Actions GITHUB_TOKEN) + // GitHub App-only permissions (not available in GitHub Actions GITHUB_TOKEN). + // Use GetExplicit() so that shorthand permissions like "read-all" do not accidentally + // expand into broad GitHub App-only grants that the user never declared. // Repository-level - if level, ok := permissions.Get(PermissionAdministration); ok { + if level, ok := permissions.GetExplicit(PermissionAdministration); ok { fields["permission-administration"] = string(level) } - if level, ok := permissions.Get(PermissionSecrets); ok { + if level, ok := permissions.GetExplicit(PermissionSecrets); ok { fields["permission-secrets"] = string(level) } - if level, ok := permissions.Get(PermissionEnvironments); ok { + if level, ok := permissions.GetExplicit(PermissionEnvironments); ok { fields["permission-environments"] = string(level) } - if level, ok := permissions.Get(PermissionGitSigning); ok { + if level, ok := permissions.GetExplicit(PermissionGitSigning); ok { fields["permission-git-signing"] = string(level) } - if level, ok := permissions.Get(PermissionVulnerabilityAlerts); ok { + if level, ok := permissions.GetExplicit(PermissionVulnerabilityAlerts); ok { fields["permission-vulnerability-alerts"] = string(level) } - if level, ok := permissions.Get(PermissionWorkflows); ok { + if level, ok := permissions.GetExplicit(PermissionWorkflows); ok { fields["permission-workflows"] = string(level) } - if level, ok := permissions.Get(PermissionRepositoryHooks); ok { + if level, ok := permissions.GetExplicit(PermissionRepositoryHooks); ok { fields["permission-repository-hooks"] = string(level) } - if level, ok := permissions.Get(PermissionSingleFile); ok { + if level, ok := permissions.GetExplicit(PermissionSingleFile); ok { fields["permission-single-file"] = string(level) } - if level, ok := permissions.Get(PermissionCodespaces); ok { + if level, ok := permissions.GetExplicit(PermissionCodespaces); ok { fields["permission-codespaces"] = string(level) } - if level, ok := permissions.Get(PermissionDependabotSecrets); ok { + if level, ok := permissions.GetExplicit(PermissionDependabotSecrets); ok { fields["permission-dependabot-secrets"] = string(level) } - if level, ok := permissions.Get(PermissionRepositoryCustomProperties); ok { + if level, ok := permissions.GetExplicit(PermissionRepositoryCustomProperties); ok { fields["permission-repository-custom-properties"] = string(level) } // Organization-level - if level, ok := permissions.Get(PermissionMembers); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationProj); ok { + fields["permission-organization-projects"] = string(level) + } + if level, ok := permissions.GetExplicit(PermissionMembers); ok { fields["permission-members"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationAdministration); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationAdministration); ok { fields["permission-organization-administration"] = string(level) } - if level, ok := permissions.Get(PermissionTeamDiscussions); ok { + if level, ok := permissions.GetExplicit(PermissionTeamDiscussions); ok { fields["permission-team-discussions"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationHooks); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationHooks); ok { fields["permission-organization-hooks"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationMembers); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationMembers); ok { fields["permission-organization-members"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationPackages); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationPackages); ok { fields["permission-organization-packages"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationSecrets); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationSecrets); ok { fields["permission-organization-secrets"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationSelfHostedRunners); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationSelfHostedRunners); ok { fields["permission-organization-self-hosted-runners"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationCustomOrgRoles); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationCustomOrgRoles); ok { fields["permission-organization-custom-org-roles"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationCustomProperties); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationCustomProperties); ok { fields["permission-organization-custom-properties"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationCustomRepositoryRoles); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationCustomRepositoryRoles); ok { fields["permission-organization-custom-repository-roles"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationAnnouncementBanners); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationAnnouncementBanners); ok { fields["permission-organization-announcement-banners"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationEvents); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationEvents); ok { fields["permission-organization-events"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationPlan); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationPlan); ok { fields["permission-organization-plan"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationUserBlocking); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationUserBlocking); ok { fields["permission-organization-user-blocking"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationPersonalAccessTokenReqs); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationPersonalAccessTokenReqs); ok { fields["permission-organization-personal-access-token-requests"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationPersonalAccessTokens); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationPersonalAccessTokens); ok { fields["permission-organization-personal-access-tokens"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationCopilot); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationCopilot); ok { fields["permission-organization-copilot"] = string(level) } - if level, ok := permissions.Get(PermissionOrganizationCodespaces); ok { + if level, ok := permissions.GetExplicit(PermissionOrganizationCodespaces); ok { fields["permission-organization-codespaces"] = string(level) } // User-level - if level, ok := permissions.Get(PermissionEmailAddresses); ok { + if level, ok := permissions.GetExplicit(PermissionEmailAddresses); ok { fields["permission-email-addresses"] = string(level) } - if level, ok := permissions.Get(PermissionCodespacesLifecycleAdmin); ok { + if level, ok := permissions.GetExplicit(PermissionCodespacesLifecycleAdmin); ok { fields["permission-codespaces-lifecycle-admin"] = string(level) } - if level, ok := permissions.Get(PermissionCodespacesMetadata); ok { + if level, ok := permissions.GetExplicit(PermissionCodespacesMetadata); ok { fields["permission-codespaces-metadata"] = string(level) } - if level, ok := permissions.Get(PermissionCodespacesSecrets); ok { + if level, ok := permissions.GetExplicit(PermissionCodespacesSecrets); ok { fields["permission-codespaces-secrets"] = string(level) } From a8d0226dc4284879c001061c7fc192bf97b1d01f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:16:43 +0000 Subject: [PATCH 06/11] Changes before error encountered Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../github_app_permissions_validation_test.go | 58 +++++++++++++++++++ pkg/workflow/permissions.go | 6 +- pkg/workflow/permissions_operations.go | 10 ++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go index 469170146bc..785beee5cc4 100644 --- a/pkg/workflow/github_app_permissions_validation_test.go +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -121,6 +121,21 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { shouldError: true, errorContains: "GitHub App-only permissions require a GitHub App", }, + { + name: "read-all shorthand should NOT require a GitHub App", + permissions: "read-all", + shouldError: false, + }, + { + name: "write-all shorthand should NOT require a GitHub App", + permissions: "write-all", + shouldError: false, + }, + { + name: "all:read should NOT require a GitHub App", + permissions: "permissions:\n all: read", + shouldError: false, + }, } for _, tt := range tests { @@ -251,6 +266,7 @@ func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { tests := []struct { name string permissions *Permissions + expectEmpty bool shouldContain []string shouldSkip []string }{ @@ -272,6 +288,17 @@ func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { }(), shouldSkip: []string{"administration: read"}, }, + { + name: "only App-only scopes returns empty string (not bare 'permissions:')", + permissions: func() *Permissions { + p := NewPermissions() + p.Set(PermissionMembers, PermissionRead) + p.Set(PermissionAdministration, PermissionRead) + return p + }(), + expectEmpty: true, + shouldSkip: []string{"members: read", "administration: read", "permissions:"}, + }, { name: "contents permission IS included in GitHub Actions YAML", permissions: func() *Permissions { @@ -300,6 +327,10 @@ func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { t.Run(tt.name, func(t *testing.T) { yaml := tt.permissions.RenderToYAML() + if tt.expectEmpty && yaml != "" { + t.Errorf("Expected empty YAML output but got:\n%s", yaml) + } + for _, expected := range tt.shouldContain { if !strings.Contains(yaml, expected) { t.Errorf("Expected YAML to contain %q, but got:\n%s", expected, yaml) @@ -406,6 +437,33 @@ func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { "permission-organization-packages": "read", }, }, + { + name: "read-all shorthand does NOT produce App-only permission fields", + permissions: func() *Permissions { + p := NewPermissionsFromShorthand("read-all") + return p + }(), + // App-only scopes must not appear when using shorthand + absentFields: []string{ + "permission-members", + "permission-administration", + "permission-organization-secrets", + "permission-workflows", + "permission-organization-projects", + }, + }, + { + name: "write-all shorthand does NOT produce App-only permission fields", + permissions: func() *Permissions { + p := NewPermissionsFromShorthand("write-all") + return p + }(), + absentFields: []string{ + "permission-members", + "permission-administration", + "permission-organization-secrets", + }, + }, } for _, tt := range tests { diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 136d2e7400c..3f8cafb89f7 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -40,8 +40,6 @@ func convertStringToPermissionScope(key string) PermissionScope { return PermissionPullRequests case "repository-projects": return PermissionRepositoryProj - case "organization-projects": - return PermissionOrganizationProj case "security-events": return PermissionSecurityEvents case "statuses": @@ -49,6 +47,10 @@ func convertStringToPermissionScope(key string) PermissionScope { case "copilot-requests": return PermissionCopilotRequests // GitHub App-only permission scopes (not supported by GITHUB_TOKEN, require a GitHub App) + // organization-projects is included here because it is a GitHub App-only scope + // (it is excluded from GetAllPermissionScopes() and skipped in YAML rendering). + case "organization-projects": + return PermissionOrganizationProj case "administration": return PermissionAdministration case "members": diff --git a/pkg/workflow/permissions_operations.go b/pkg/workflow/permissions_operations.go index b4b12233ad5..3e043b498a8 100644 --- a/pkg/workflow/permissions_operations.go +++ b/pkg/workflow/permissions_operations.go @@ -266,6 +266,7 @@ func (p *Permissions) RenderToYAML() string { var lines []string lines = append(lines, "permissions:") + hasRenderable := false for _, scopeStr := range scopes { scope := PermissionScope(scopeStr) level := allPerms[scope] @@ -282,6 +283,7 @@ func (p *Permissions) RenderToYAML() string { continue } + hasRenderable = true // Add 2 spaces for proper indentation under permissions: // When rendered in a job, the job renderer adds 4 spaces to the first line only, // so we need to pre-indent continuation lines with 4 additional spaces @@ -289,5 +291,13 @@ func (p *Permissions) RenderToYAML() string { lines = append(lines, fmt.Sprintf(" %s: %s", scope, level)) } + // If everything was skipped (all App-only or metadata), return as if empty + if !hasRenderable { + if p.explicitEmpty { + return "permissions: {}" + } + return "" + } + return strings.Join(lines, "\n") } From 0e3885f84bcae540378a00cbc2f5eb4ca1d826ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:20:35 +0000 Subject: [PATCH 07/11] fix: address review feedback on GitHub App-only permissions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../github_app_permissions_validation_test.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go index 785beee5cc4..d741c83d9f7 100644 --- a/pkg/workflow/github_app_permissions_validation_test.go +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -438,11 +438,8 @@ func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { }, }, { - name: "read-all shorthand does NOT produce App-only permission fields", - permissions: func() *Permissions { - p := NewPermissionsFromShorthand("read-all") - return p - }(), + name: "read-all shorthand does NOT produce App-only permission fields", + permissions: NewPermissionsReadAll(), // App-only scopes must not appear when using shorthand absentFields: []string{ "permission-members", @@ -453,11 +450,8 @@ func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { }, }, { - name: "write-all shorthand does NOT produce App-only permission fields", - permissions: func() *Permissions { - p := NewPermissionsFromShorthand("write-all") - return p - }(), + name: "write-all shorthand does NOT produce App-only permission fields", + permissions: NewPermissionsWriteAll(), absentFields: []string{ "permission-members", "permission-administration", From 74c710193fd67329243fc22808ba6c23992599df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:52:28 +0000 Subject: [PATCH 08/11] feat: remove secrets-related permissions from GitHub App-only list Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/frontmatter_types.go | 24 --------------- .../github_app_permissions_validation_test.go | 30 ------------------- pkg/workflow/permissions.go | 16 ---------- pkg/workflow/safe_outputs_app_config.go | 12 -------- 4 files changed, 82 deletions(-) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index b4fb2f3f1ee..56592bcd72a 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -66,7 +66,6 @@ type GitHubAppPermissionsConfig struct { OrganizationHooks string `json:"organization-hooks,omitempty"` OrganizationMembers string `json:"organization-members,omitempty"` OrganizationPackages string `json:"organization-packages,omitempty"` - OrganizationSecrets string `json:"organization-secrets,omitempty"` OrganizationSelfHostedRunners string `json:"organization-self-hosted-runners,omitempty"` OrganizationCustomOrgRoles string `json:"organization-custom-org-roles,omitempty"` OrganizationCustomProperties string `json:"organization-custom-properties,omitempty"` @@ -81,7 +80,6 @@ type GitHubAppPermissionsConfig struct { OrganizationCodespaces string `json:"organization-codespaces,omitempty"` // Repository-level permissions Administration string `json:"administration,omitempty"` - Secrets string `json:"secrets,omitempty"` Environments string `json:"environments,omitempty"` GitSigning string `json:"git-signing,omitempty"` VulnerabilityAlerts string `json:"vulnerability-alerts,omitempty"` @@ -89,13 +87,11 @@ type GitHubAppPermissionsConfig struct { RepositoryHooks string `json:"repository-hooks,omitempty"` SingleFile string `json:"single-file,omitempty"` Codespaces string `json:"codespaces,omitempty"` - DependabotSecrets string `json:"dependabot-secrets,omitempty"` RepositoryCustomProperties string `json:"repository-custom-properties,omitempty"` // User-level permissions EmailAddresses string `json:"email-addresses,omitempty"` CodespacesLifecycleAdmin string `json:"codespaces-lifecycle-admin,omitempty"` CodespacesMetadata string `json:"codespaces-metadata,omitempty"` - CodespacesSecrets string `json:"codespaces-secrets,omitempty"` } // PermissionsConfig represents GitHub Actions permissions configuration. @@ -442,8 +438,6 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err // GitHub App-only permission scopes case "administration": config.Administration = levelStr - case "secrets": - config.Secrets = levelStr case "environments": config.Environments = levelStr case "git-signing": @@ -458,8 +452,6 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err config.SingleFile = levelStr case "codespaces": config.Codespaces = levelStr - case "dependabot-secrets": - config.DependabotSecrets = levelStr case "repository-custom-properties": config.RepositoryCustomProperties = levelStr case "members": @@ -474,8 +466,6 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err config.OrganizationMembers = levelStr case "organization-packages": config.OrganizationPackages = levelStr - case "organization-secrets": - config.OrganizationSecrets = levelStr case "organization-self-hosted-runners": config.OrganizationSelfHostedRunners = levelStr case "organization-custom-org-roles": @@ -506,8 +496,6 @@ func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, err config.CodespacesLifecycleAdmin = levelStr case "codespaces-metadata": config.CodespacesMetadata = levelStr - case "codespaces-secrets": - config.CodespacesSecrets = levelStr } } } @@ -937,9 +925,6 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.Administration != "" { result["administration"] = config.Administration } - if config.Secrets != "" { - result["secrets"] = config.Secrets - } if config.Environments != "" { result["environments"] = config.Environments } @@ -961,9 +946,6 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.Codespaces != "" { result["codespaces"] = config.Codespaces } - if config.DependabotSecrets != "" { - result["dependabot-secrets"] = config.DependabotSecrets - } if config.RepositoryCustomProperties != "" { result["repository-custom-properties"] = config.RepositoryCustomProperties } @@ -987,9 +969,6 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.OrganizationPackages != "" { result["organization-packages"] = config.OrganizationPackages } - if config.OrganizationSecrets != "" { - result["organization-secrets"] = config.OrganizationSecrets - } if config.OrganizationSelfHostedRunners != "" { result["organization-self-hosted-runners"] = config.OrganizationSelfHostedRunners } @@ -1037,9 +1016,6 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { if config.CodespacesMetadata != "" { result["codespaces-metadata"] = config.CodespacesMetadata } - if config.CodespacesSecrets != "" { - result["codespaces-secrets"] = config.CodespacesSecrets - } if len(result) == 0 { return nil diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go index d741c83d9f7..8f1c356da15 100644 --- a/pkg/workflow/github_app_permissions_validation_test.go +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -45,12 +45,6 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { shouldError: true, errorContains: "GitHub App-only permissions require a GitHub App", }, - { - name: "secrets permission without github-app - should error", - permissions: "permissions:\n secrets: read", - shouldError: true, - errorContains: "GitHub App-only permissions require a GitHub App", - }, { name: "members permission with tools.github.github-app - should pass", permissions: "permissions:\n members: read", @@ -84,12 +78,6 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { }, shouldError: false, }, - { - name: "organization-secrets permission without github-app - should error", - permissions: "permissions:\n organization-secrets: read", - shouldError: true, - errorContains: "organization-secrets", - }, { name: "workflows permission without github-app - should error", permissions: "permissions:\n workflows: write", @@ -189,7 +177,6 @@ func TestIsGitHubAppOnlyScope(t *testing.T) { {PermissionAdministration, true}, {PermissionMembers, true}, {PermissionOrganizationAdministration, true}, - {PermissionSecrets, true}, {PermissionEnvironments, true}, {PermissionGitSigning, true}, {PermissionTeamDiscussions, true}, @@ -199,11 +186,9 @@ func TestIsGitHubAppOnlyScope(t *testing.T) { {PermissionOrganizationHooks, true}, {PermissionOrganizationMembers, true}, {PermissionOrganizationPackages, true}, - {PermissionOrganizationSecrets, true}, {PermissionOrganizationSelfHostedRunners, true}, {PermissionSingleFile, true}, {PermissionCodespaces, true}, - {PermissionDependabotSecrets, true}, {PermissionEmailAddresses, true}, } @@ -228,11 +213,9 @@ func TestGetAllGitHubAppOnlyScopes(t *testing.T) { PermissionAdministration, PermissionMembers, PermissionOrganizationAdministration, - PermissionSecrets, PermissionEnvironments, PermissionWorkflows, PermissionVulnerabilityAlerts, - PermissionOrganizationSecrets, PermissionOrganizationPackages, } @@ -375,17 +358,6 @@ func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { "permission-administration": "read", }, }, - { - name: "organization-secrets permission maps correctly", - permissions: func() *Permissions { - p := NewPermissions() - p.Set(PermissionOrganizationSecrets, PermissionRead) - return p - }(), - expectedFields: map[string]string{ - "permission-organization-secrets": "read", - }, - }, { name: "workflows permission maps to permission-workflows", permissions: func() *Permissions { @@ -444,7 +416,6 @@ func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { absentFields: []string{ "permission-members", "permission-administration", - "permission-organization-secrets", "permission-workflows", "permission-organization-projects", }, @@ -455,7 +426,6 @@ func TestConvertPermissionsToAppTokenFields_GitHubAppOnly(t *testing.T) { absentFields: []string{ "permission-members", "permission-administration", - "permission-organization-secrets", }, }, } diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 3f8cafb89f7..baa36c363d5 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -57,8 +57,6 @@ func convertStringToPermissionScope(key string) PermissionScope { return PermissionMembers case "organization-administration": return PermissionOrganizationAdministration - case "secrets": - return PermissionSecrets case "environments": return PermissionEnvironments case "git-signing": @@ -77,16 +75,12 @@ func convertStringToPermissionScope(key string) PermissionScope { return PermissionOrganizationMembers case "organization-packages": return PermissionOrganizationPackages - case "organization-secrets": - return PermissionOrganizationSecrets case "organization-self-hosted-runners": return PermissionOrganizationSelfHostedRunners case "single-file": return PermissionSingleFile case "codespaces": return PermissionCodespaces - case "dependabot-secrets": - return PermissionDependabotSecrets case "email-addresses": return PermissionEmailAddresses case "repository-custom-properties": @@ -117,8 +111,6 @@ func convertStringToPermissionScope(key string) PermissionScope { return PermissionCodespacesLifecycleAdmin case "codespaces-metadata": return PermissionCodespacesMetadata - case "codespaces-secrets": - return PermissionCodespacesSecrets case "all": // "all" is a meta-key handled at the parser level; it is not a real scope return "" @@ -170,7 +162,6 @@ const ( // Repository-level GitHub App permissions PermissionAdministration PermissionScope = "administration" - PermissionSecrets PermissionScope = "secrets" PermissionEnvironments PermissionScope = "environments" PermissionGitSigning PermissionScope = "git-signing" PermissionVulnerabilityAlerts PermissionScope = "vulnerability-alerts" @@ -178,7 +169,6 @@ const ( PermissionRepositoryHooks PermissionScope = "repository-hooks" PermissionSingleFile PermissionScope = "single-file" PermissionCodespaces PermissionScope = "codespaces" - PermissionDependabotSecrets PermissionScope = "dependabot-secrets" PermissionRepositoryCustomProperties PermissionScope = "repository-custom-properties" // Organization-level GitHub App permissions @@ -188,7 +178,6 @@ const ( PermissionOrganizationHooks PermissionScope = "organization-hooks" PermissionOrganizationMembers PermissionScope = "organization-members" PermissionOrganizationPackages PermissionScope = "organization-packages" - PermissionOrganizationSecrets PermissionScope = "organization-secrets" PermissionOrganizationSelfHostedRunners PermissionScope = "organization-self-hosted-runners" PermissionOrganizationCustomOrgRoles PermissionScope = "organization-custom-org-roles" PermissionOrganizationCustomProperties PermissionScope = "organization-custom-properties" @@ -206,7 +195,6 @@ const ( PermissionEmailAddresses PermissionScope = "email-addresses" PermissionCodespacesLifecycleAdmin PermissionScope = "codespaces-lifecycle-admin" PermissionCodespacesMetadata PermissionScope = "codespaces-metadata" - PermissionCodespacesSecrets PermissionScope = "codespaces-secrets" ) // GetAllPermissionScopes returns all GitHub Actions permission scopes (supported by GITHUB_TOKEN). @@ -240,7 +228,6 @@ func GetAllGitHubAppOnlyScopes() []PermissionScope { return []PermissionScope{ // Repository-level GitHub App permissions PermissionAdministration, - PermissionSecrets, PermissionEnvironments, PermissionGitSigning, PermissionVulnerabilityAlerts, @@ -248,7 +235,6 @@ func GetAllGitHubAppOnlyScopes() []PermissionScope { PermissionRepositoryHooks, PermissionSingleFile, PermissionCodespaces, - PermissionDependabotSecrets, PermissionRepositoryCustomProperties, // Organization-level GitHub App permissions PermissionOrganizationProj, @@ -258,7 +244,6 @@ func GetAllGitHubAppOnlyScopes() []PermissionScope { PermissionOrganizationHooks, PermissionOrganizationMembers, PermissionOrganizationPackages, - PermissionOrganizationSecrets, PermissionOrganizationSelfHostedRunners, PermissionOrganizationCustomOrgRoles, PermissionOrganizationCustomProperties, @@ -275,7 +260,6 @@ func GetAllGitHubAppOnlyScopes() []PermissionScope { PermissionEmailAddresses, PermissionCodespacesLifecycleAdmin, PermissionCodespacesMetadata, - PermissionCodespacesSecrets, } } diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 02eaff853f0..92b7bbddf7f 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -255,9 +255,6 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.GetExplicit(PermissionAdministration); ok { fields["permission-administration"] = string(level) } - if level, ok := permissions.GetExplicit(PermissionSecrets); ok { - fields["permission-secrets"] = string(level) - } if level, ok := permissions.GetExplicit(PermissionEnvironments); ok { fields["permission-environments"] = string(level) } @@ -279,9 +276,6 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.GetExplicit(PermissionCodespaces); ok { fields["permission-codespaces"] = string(level) } - if level, ok := permissions.GetExplicit(PermissionDependabotSecrets); ok { - fields["permission-dependabot-secrets"] = string(level) - } if level, ok := permissions.GetExplicit(PermissionRepositoryCustomProperties); ok { fields["permission-repository-custom-properties"] = string(level) } @@ -307,9 +301,6 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.GetExplicit(PermissionOrganizationPackages); ok { fields["permission-organization-packages"] = string(level) } - if level, ok := permissions.GetExplicit(PermissionOrganizationSecrets); ok { - fields["permission-organization-secrets"] = string(level) - } if level, ok := permissions.GetExplicit(PermissionOrganizationSelfHostedRunners); ok { fields["permission-organization-self-hosted-runners"] = string(level) } @@ -356,9 +347,6 @@ func convertPermissionsToAppTokenFields(permissions *Permissions) map[string]str if level, ok := permissions.GetExplicit(PermissionCodespacesMetadata); ok { fields["permission-codespaces-metadata"] = string(level) } - if level, ok := permissions.GetExplicit(PermissionCodespacesSecrets); ok { - fields["permission-codespaces-secrets"] = string(level) - } // Note: The following GitHub Actions permissions do NOT have GitHub App equivalents // and are therefore not mapped to permission-* fields: From 35dfe24f4f87f5f56b407908ba0b4b8e428a9970 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:27:01 +0000 Subject: [PATCH 09/11] feat: refuse write level for read-only GitHub App-only permission scopes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../github_app_permissions_validation.go | 53 +++++++++++- .../github_app_permissions_validation_test.go | 82 +++++++++++++++++++ pkg/workflow/permissions.go | 17 ++++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/github_app_permissions_validation.go b/pkg/workflow/github_app_permissions_validation.go index 0897d4c196c..682724eb0ec 100644 --- a/pkg/workflow/github_app_permissions_validation.go +++ b/pkg/workflow/github_app_permissions_validation.go @@ -1,7 +1,7 @@ package workflow import ( - "fmt" + "errors" "sort" "strings" ) @@ -18,7 +18,8 @@ var githubAppPermissionsLog = newValidationLogger("github_app_permissions") // - safe-outputs.github-app // - the top-level github-app field (for the activation/pre-activation jobs) // -// Returns an error if GitHub App-only permissions are used without any GitHub App configured. +// Returns an error if GitHub App-only permissions are used without any GitHub App configured, +// or if "write" level is requested for a read-only GitHub App-only scope. func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { githubAppPermissionsLog.Print("Starting GitHub App-only permissions validation") @@ -51,6 +52,11 @@ func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { githubAppPermissionsLog.Printf("Found %d GitHub App-only permissions, checking for GitHub App configuration", len(appOnlyScopes)) + // Check if "write" is requested for any read-only GitHub App-only scopes. + if err := validateGitHubAppOnlyPermissionsWrite(permissions, appOnlyScopes); err != nil { + return err + } + // Check if any GitHub App is configured if hasGitHubAppConfigured(workflowData) { githubAppPermissionsLog.Print("GitHub App is configured, validation passed") @@ -61,7 +67,46 @@ func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { return formatGitHubAppRequiredError(appOnlyScopes) } -// hasGitHubAppConfigured returns true if a GitHub App is configured anywhere in the workflow +// validateGitHubAppOnlyPermissionsWrite checks that no read-only GitHub App-only scope +// has been requested with "write" access. Such a request is always invalid because +// the GitHub App permission model does not allow "write" for those scopes. +func validateGitHubAppOnlyPermissionsWrite(permissions *Permissions, appOnlyScopes []PermissionScope) error { + var writeOnReadOnly []PermissionScope + for _, scope := range appOnlyScopes { + if !IsReadOnlyGitHubAppOnlyScope(scope) { + continue + } + if level, exists := permissions.GetExplicit(scope); exists && level == PermissionWrite { + writeOnReadOnly = append(writeOnReadOnly, scope) + } + } + if len(writeOnReadOnly) == 0 { + return nil + } + return formatWriteOnReadOnlyScopesError(writeOnReadOnly) +} + +// formatWriteOnReadOnlyScopesError formats the error when "write" is requested for +// a read-only GitHub App-only scope. +func formatWriteOnReadOnlyScopesError(scopes []PermissionScope) error { + scopeStrs := make([]string, len(scopes)) + for i, s := range scopes { + scopeStrs[i] = string(s) + } + sort.Strings(scopeStrs) + + var lines []string + lines = append(lines, "The following GitHub App permissions are read-only and do not support \"write\" access:") + lines = append(lines, "") + for _, s := range scopeStrs { + lines = append(lines, " - "+s) + } + lines = append(lines, "") + lines = append(lines, "Change the permission level to \"read\" or remove these permissions.") + + return errors.New(strings.Join(lines, "\n")) +} + func hasGitHubAppConfigured(workflowData *WorkflowData) bool { // Check tools.github.github-app if workflowData.ParsedTools != nil && @@ -118,5 +163,5 @@ func formatGitHubAppRequiredError(appOnlyScopes []PermissionScope) error { lines = append(lines, " app-id: ${{ vars.APP_ID }}") lines = append(lines, " private-key: ${{ secrets.APP_PRIVATE_KEY }}") - return fmt.Errorf("%s", strings.Join(lines, "\n")) + return errors.New(strings.Join(lines, "\n")) } diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go index 8f1c356da15..f86db1efb06 100644 --- a/pkg/workflow/github_app_permissions_validation_test.go +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -124,6 +124,56 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { permissions: "permissions:\n all: read", shouldError: false, }, + { + name: "organization-events with write - should error (read-only scope)", + permissions: "permissions:\n organization-events: write", + shouldError: true, + errorContains: "read-only", + }, + { + name: "organization-plan with write - should error (read-only scope)", + permissions: "permissions:\n organization-plan: write", + shouldError: true, + errorContains: "read-only", + }, + { + name: "email-addresses with write - should error (read-only scope)", + permissions: "permissions:\n email-addresses: write", + shouldError: true, + errorContains: "read-only", + }, + { + name: "codespaces-metadata with write - should error (read-only scope)", + permissions: "permissions:\n codespaces-metadata: write", + shouldError: true, + errorContains: "read-only", + }, + { + name: "organization-events with read and github-app - should pass", + permissions: "permissions:\n organization-events: read", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, + { + name: "administration with write and github-app - should pass (write allowed)", + permissions: "permissions:\n administration: write", + parsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + shouldError: false, + }, } for _, tt := range tests { @@ -245,6 +295,38 @@ func TestGetAllGitHubAppOnlyScopes(t *testing.T) { } } +func TestIsReadOnlyGitHubAppOnlyScope(t *testing.T) { + tests := []struct { + scope PermissionScope + expected bool + }{ + // Read-only GitHub App-only scopes - should return true + {PermissionOrganizationEvents, true}, + {PermissionOrganizationPlan, true}, + {PermissionEmailAddresses, true}, + {PermissionCodespacesMetadata, true}, + // Scopes that support both read and write - should return false + {PermissionAdministration, false}, + {PermissionMembers, false}, + {PermissionWorkflows, false}, + {PermissionOrganizationAdministration, false}, + {PermissionTeamDiscussions, false}, + {PermissionEnvironments, false}, + // GitHub Actions scopes - not App-only, should return false + {PermissionContents, false}, + {PermissionIssues, false}, + } + + for _, tt := range tests { + t.Run(string(tt.scope), func(t *testing.T) { + result := IsReadOnlyGitHubAppOnlyScope(tt.scope) + if result != tt.expected { + t.Errorf("IsReadOnlyGitHubAppOnlyScope(%q) = %v, want %v", tt.scope, result, tt.expected) + } + }) + } +} + func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { tests := []struct { name string diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index baa36c363d5..909b222c70a 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -269,6 +269,23 @@ func IsGitHubAppOnlyScope(scope PermissionScope) bool { return slices.Contains(GetAllGitHubAppOnlyScopes(), scope) } +// GetReadOnlyGitHubAppOnlyScopes returns the GitHub App-only scopes that only support +// the "read" permission level. Requesting "write" for any of these scopes is an error. +func GetReadOnlyGitHubAppOnlyScopes() []PermissionScope { + return []PermissionScope{ + PermissionOrganizationEvents, // events are always read-only + PermissionOrganizationPlan, // billing plan info is read-only + PermissionEmailAddresses, // user email addresses are read-only via App + PermissionCodespacesMetadata, // metadata is read-only + } +} + +// IsReadOnlyGitHubAppOnlyScope returns true if the scope is a GitHub App-only permission +// that only supports the "read" level (i.e., "write" is not a valid level for this scope). +func IsReadOnlyGitHubAppOnlyScope(scope PermissionScope) bool { + return slices.Contains(GetReadOnlyGitHubAppOnlyScopes(), scope) +} + // Permissions represents GitHub Actions permissions // It can be a shorthand (read-all, write-all, read, write, none) or a map of scopes to levels // It can also have an "all" permission that expands to all scopes From 8e64e409e91cf0660e510ca5877564c715603302 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:44:14 +0000 Subject: [PATCH 10/11] feat: refuse write for all GitHub App-only permission scopes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../github_app_permissions_validation.go | 38 +++++------ .../github_app_permissions_validation_test.go | 63 ++++++------------- pkg/workflow/permissions.go | 17 ----- 3 files changed, 39 insertions(+), 79 deletions(-) diff --git a/pkg/workflow/github_app_permissions_validation.go b/pkg/workflow/github_app_permissions_validation.go index 682724eb0ec..2158f7bceb4 100644 --- a/pkg/workflow/github_app_permissions_validation.go +++ b/pkg/workflow/github_app_permissions_validation.go @@ -9,9 +9,11 @@ import ( var githubAppPermissionsLog = newValidationLogger("github_app_permissions") // validateGitHubAppOnlyPermissions validates that when GitHub App-only permissions -// are specified in the workflow, a GitHub App is configured somewhere in the workflow. +// are specified in the workflow, a GitHub App is configured somewhere in the workflow, +// and that no GitHub App-only permission is declared with "write" access (write operations +// must be performed via safe-outputs, not through declared permissions). // -// GitHub App-only permissions (e.g., members, administration, secrets) cannot be exercised +// GitHub App-only permissions (e.g., members, administration) cannot be exercised // through the GITHUB_TOKEN — they require a GitHub App installation access token. When such // permissions are declared, a GitHub App must be configured via one of: // - tools.github.github-app @@ -19,7 +21,7 @@ var githubAppPermissionsLog = newValidationLogger("github_app_permissions") // - the top-level github-app field (for the activation/pre-activation jobs) // // Returns an error if GitHub App-only permissions are used without any GitHub App configured, -// or if "write" level is requested for a read-only GitHub App-only scope. +// or if "write" level is requested for any GitHub App-only scope. func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { githubAppPermissionsLog.Print("Starting GitHub App-only permissions validation") @@ -67,28 +69,26 @@ func validateGitHubAppOnlyPermissions(workflowData *WorkflowData) error { return formatGitHubAppRequiredError(appOnlyScopes) } -// validateGitHubAppOnlyPermissionsWrite checks that no read-only GitHub App-only scope -// has been requested with "write" access. Such a request is always invalid because -// the GitHub App permission model does not allow "write" for those scopes. +// validateGitHubAppOnlyPermissionsWrite checks that no GitHub App-only scope has been +// requested with "write" access. Write operations on GitHub App tokens must be performed +// via safe-outputs, not through declared permissions. func validateGitHubAppOnlyPermissionsWrite(permissions *Permissions, appOnlyScopes []PermissionScope) error { - var writeOnReadOnly []PermissionScope + var writtenScopes []PermissionScope for _, scope := range appOnlyScopes { - if !IsReadOnlyGitHubAppOnlyScope(scope) { - continue - } if level, exists := permissions.GetExplicit(scope); exists && level == PermissionWrite { - writeOnReadOnly = append(writeOnReadOnly, scope) + writtenScopes = append(writtenScopes, scope) } } - if len(writeOnReadOnly) == 0 { + if len(writtenScopes) == 0 { return nil } - return formatWriteOnReadOnlyScopesError(writeOnReadOnly) + return formatWriteOnAppScopesError(writtenScopes) } -// formatWriteOnReadOnlyScopesError formats the error when "write" is requested for -// a read-only GitHub App-only scope. -func formatWriteOnReadOnlyScopesError(scopes []PermissionScope) error { +// formatWriteOnAppScopesError formats the error when "write" is requested for +// any GitHub App-only scope. All App-only scopes must be declared read-only; +// write operations are performed via safe-outputs. +func formatWriteOnAppScopesError(scopes []PermissionScope) error { scopeStrs := make([]string, len(scopes)) for i, s := range scopes { scopeStrs[i] = string(s) @@ -96,13 +96,15 @@ func formatWriteOnReadOnlyScopesError(scopes []PermissionScope) error { sort.Strings(scopeStrs) var lines []string - lines = append(lines, "The following GitHub App permissions are read-only and do not support \"write\" access:") + lines = append(lines, "GitHub App permissions must be declared as \"read\" only.") + lines = append(lines, "Write operations are performed via safe-outputs, not through declared permissions.") + lines = append(lines, "The following GitHub App-only permissions were declared with \"write\" access:") lines = append(lines, "") for _, s := range scopeStrs { lines = append(lines, " - "+s) } lines = append(lines, "") - lines = append(lines, "Change the permission level to \"read\" or remove these permissions.") + lines = append(lines, "Change the permission level to \"read\" or use safe-outputs for write operations.") return errors.New(strings.Join(lines, "\n")) } diff --git a/pkg/workflow/github_app_permissions_validation_test.go b/pkg/workflow/github_app_permissions_validation_test.go index f86db1efb06..017b0a994cc 100644 --- a/pkg/workflow/github_app_permissions_validation_test.go +++ b/pkg/workflow/github_app_permissions_validation_test.go @@ -29,7 +29,7 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { }, { name: "organization-projects (App-only) without github-app - should error", - permissions: "permissions:\n organization-projects: write", + permissions: "permissions:\n organization-projects: read", shouldError: true, errorContains: "GitHub App-only permissions require a GitHub App", }, @@ -125,28 +125,28 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { shouldError: false, }, { - name: "organization-events with write - should error (read-only scope)", + name: "organization-events with write - should error (no write on App-only scopes)", permissions: "permissions:\n organization-events: write", shouldError: true, - errorContains: "read-only", + errorContains: "safe-outputs", }, { - name: "organization-plan with write - should error (read-only scope)", + name: "organization-plan with write - should error (no write on App-only scopes)", permissions: "permissions:\n organization-plan: write", shouldError: true, - errorContains: "read-only", + errorContains: "safe-outputs", }, { - name: "email-addresses with write - should error (read-only scope)", + name: "email-addresses with write - should error (no write on App-only scopes)", permissions: "permissions:\n email-addresses: write", shouldError: true, - errorContains: "read-only", + errorContains: "safe-outputs", }, { - name: "codespaces-metadata with write - should error (read-only scope)", + name: "codespaces-metadata with write - should error (no write on App-only scopes)", permissions: "permissions:\n codespaces-metadata: write", shouldError: true, - errorContains: "read-only", + errorContains: "safe-outputs", }, { name: "organization-events with read and github-app - should pass", @@ -162,8 +162,14 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { shouldError: false, }, { - name: "administration with write and github-app - should pass (write allowed)", - permissions: "permissions:\n administration: write", + name: "administration with write - should error (no write on App-only scopes)", + permissions: "permissions:\n administration: write", + shouldError: true, + errorContains: "safe-outputs", + }, + { + name: "members with write - should error even with github-app (no write on App-only scopes)", + permissions: "permissions:\n members: write", parsedTools: &ToolsConfig{ GitHub: &GitHubToolConfig{ GitHubApp: &GitHubAppConfig{ @@ -172,7 +178,8 @@ func TestValidateGitHubAppOnlyPermissions(t *testing.T) { }, }, }, - shouldError: false, + shouldError: true, + errorContains: "safe-outputs", }, } @@ -295,38 +302,6 @@ func TestGetAllGitHubAppOnlyScopes(t *testing.T) { } } -func TestIsReadOnlyGitHubAppOnlyScope(t *testing.T) { - tests := []struct { - scope PermissionScope - expected bool - }{ - // Read-only GitHub App-only scopes - should return true - {PermissionOrganizationEvents, true}, - {PermissionOrganizationPlan, true}, - {PermissionEmailAddresses, true}, - {PermissionCodespacesMetadata, true}, - // Scopes that support both read and write - should return false - {PermissionAdministration, false}, - {PermissionMembers, false}, - {PermissionWorkflows, false}, - {PermissionOrganizationAdministration, false}, - {PermissionTeamDiscussions, false}, - {PermissionEnvironments, false}, - // GitHub Actions scopes - not App-only, should return false - {PermissionContents, false}, - {PermissionIssues, false}, - } - - for _, tt := range tests { - t.Run(string(tt.scope), func(t *testing.T) { - result := IsReadOnlyGitHubAppOnlyScope(tt.scope) - if result != tt.expected { - t.Errorf("IsReadOnlyGitHubAppOnlyScope(%q) = %v, want %v", tt.scope, result, tt.expected) - } - }) - } -} - func TestGitHubAppOnlyPermissionsRenderToYAML(t *testing.T) { tests := []struct { name string diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 909b222c70a..baa36c363d5 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -269,23 +269,6 @@ func IsGitHubAppOnlyScope(scope PermissionScope) bool { return slices.Contains(GetAllGitHubAppOnlyScopes(), scope) } -// GetReadOnlyGitHubAppOnlyScopes returns the GitHub App-only scopes that only support -// the "read" permission level. Requesting "write" for any of these scopes is an error. -func GetReadOnlyGitHubAppOnlyScopes() []PermissionScope { - return []PermissionScope{ - PermissionOrganizationEvents, // events are always read-only - PermissionOrganizationPlan, // billing plan info is read-only - PermissionEmailAddresses, // user email addresses are read-only via App - PermissionCodespacesMetadata, // metadata is read-only - } -} - -// IsReadOnlyGitHubAppOnlyScope returns true if the scope is a GitHub App-only permission -// that only supports the "read" level (i.e., "write" is not a valid level for this scope). -func IsReadOnlyGitHubAppOnlyScope(scope PermissionScope) bool { - return slices.Contains(GetReadOnlyGitHubAppOnlyScopes(), scope) -} - // Permissions represents GitHub Actions permissions // It can be a shorthand (read-all, write-all, read, write, none) or a map of scopes to levels // It can also have an "all" permission that expands to all scopes From 310f2c9118288f66754f4d32ab2b4281296dd93a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:58:59 +0000 Subject: [PATCH 11/11] docs: document GitHub App-only permissions Add a new '### GitHub App-Only Permissions' section to the permissions reference documenting scopes that cannot be set on GITHUB_TOKEN. These scopes are omitted from the compiled permissions block and forwarded to actions/create-github-app-token when a GitHub App is configured. Cover: full scope list by level, read-only constraint, github-app configuration requirement, and shorthand exemption. Also update the Write Permission Policy exceptions list to mention that GitHub App-only scopes always refuse write at compile time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/content/docs/reference/permissions.md | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/reference/permissions.md b/docs/src/content/docs/reference/permissions.md index b38bc95b0ee..e25fc38540f 100644 --- a/docs/src/content/docs/reference/permissions.md +++ b/docs/src/content/docs/reference/permissions.md @@ -43,6 +43,35 @@ permissions: This permission is safe to use and does not require safe-outputs, even in strict mode. +### GitHub App-Only Permissions + +Certain permission scopes cannot be granted to `GITHUB_TOKEN` and are forwarded instead as inputs to [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token) when a GitHub App is configured. These scopes are omitted from the compiled workflow's `permissions:` block. + +**Repository-level:** `administration`, `environments`, `git-signing`, `vulnerability-alerts`, `workflows`, `repository-hooks`, `single-file`, `codespaces`, `repository-custom-properties` + +**Organization-level:** `organization-projects`, `members`, `organization-administration`, `team-discussions`, `organization-hooks`, `organization-members`, `organization-packages`, `organization-self-hosted-runners`, `organization-custom-org-roles`, `organization-custom-properties`, `organization-custom-repository-roles`, `organization-announcement-banners`, `organization-events`, `organization-plan`, `organization-user-blocking`, `organization-personal-access-token-requests`, `organization-personal-access-tokens`, `organization-copilot`, `organization-codespaces` + +**User-level:** `email-addresses`, `codespaces-lifecycle-admin`, `codespaces-metadata` + +These scopes must always be declared as `read`. Declaring `write` is a compile error; write operations through a GitHub App must go through [safe outputs](/gh-aw/reference/safe-outputs/), which provide a separate sanitized job for write operations. + +Declaring any of these scopes without a configured `github-app` causes a compile error. The GitHub App can be configured in `tools.github.github-app`, `safe-outputs.github-app`, or the top-level `github-app:` field — see [Tools](/gh-aw/reference/tools/) for configuration details. + +```aw wrap +permissions: + contents: read + workflows: read # GitHub App-only scope + members: read # GitHub App-only scope +tools: + github: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +``` + +> [!NOTE] +> Shorthand permissions (`read-all`, `write-all`, `all: read`) do not trigger the "GitHub App required" validation. + ## Configuration Specify individual permission levels: @@ -116,7 +145,9 @@ permissions: contents: read ``` -**Exception:** The `id-token: write` permission is explicitly allowed as it is used for OIDC authentication with cloud providers and does not grant repository write access. +**Exceptions:** +- `id-token: write` is allowed for OIDC authentication with cloud providers and does not grant repository write access. +- GitHub App-only scopes (see above) always refuse `write` at compile time regardless of this policy; use [safe outputs](/gh-aw/reference/safe-outputs/) for write operations that require a GitHub App. #### Migrating Existing Workflows