diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 64e1c1c4913..166491a5d5f 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2147,106 +2147,7 @@ "description": "Simple permissions string: 'read-all' (all read permissions) or 'write-all' (all write permissions)" }, { - "type": "object", - "description": "Detailed permissions object with granular control over specific GitHub API scopes", - "additionalProperties": false, - "properties": { - "actions": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for GitHub Actions workflows and runs (read: view workflows, write: manage workflows, none: no access)" - }, - "attestations": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for artifact attestations (read: view attestations, write: create attestations, none: no access)" - }, - "checks": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for repository checks and status checks (read: view checks, write: create/update checks, none: no access)" - }, - "contents": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for repository contents (read: view files, write: modify files/branches, none: no access)" - }, - "deployments": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for repository deployments (read: view deployments, write: create/update deployments, none: no access)" - }, - "discussions": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for repository discussions (read: view discussions, write: create/update discussions, none: no access)" - }, - "id-token": { - "type": "string", - "enum": ["write", "none"], - "description": "Permission level for OIDC token requests (write/none only - read is not supported). Allows workflows to request JWT tokens for cloud provider authentication." - }, - "issues": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for repository issues (read: view issues, write: create/update/close issues, none: no access)" - }, - "models": { - "type": "string", - "enum": ["read", "none"], - "description": "Permission for GitHub Copilot models (read: access AI models for agentic workflows, none: no access)" - }, - "metadata": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission for repository metadata (read: view repository information, write: update repository metadata, none: no access)" - }, - "packages": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for GitHub Packages (read/write/none). Controls access to publish, modify, or delete packages." - }, - "pages": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for GitHub Pages (read/write/none). Controls access to deploy and manage GitHub Pages sites." - }, - "pull-requests": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for pull requests (read/write/none). Controls access to create, edit, review, and manage pull requests." - }, - "repository-projects": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for repository projects (read/write/none). Controls access to manage repository-level GitHub Projects boards." - }, - "organization-projects": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for organization projects (read/write/none). Controls access to manage organization-level GitHub Projects boards." - }, - "security-events": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for security events (read/write/none). Controls access to view and manage code scanning alerts and security findings." - }, - "statuses": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for commit statuses (read/write/none). Controls access to create and update commit status checks." - }, - "vulnerability-alerts": { - "type": "string", - "enum": ["read", "write", "none"], - "description": "Permission level for Dependabot vulnerability alerts (read/write/none). GitHub App-only permission: required to access Dependabot alerts via the GitHub MCP server. The GITHUB_TOKEN does not have this permission \u2014 a GitHub App must be configured." - }, - "all": { - "type": "string", - "enum": ["read"], - "description": "Permission shorthand that applies read access to all permission scopes. Can be combined with specific write permissions to override individual scopes. 'write' is not allowed for all." - } - } + "$ref": "#/$defs/github_actions_permissions" } ] }, @@ -3698,18 +3599,7 @@ }, "github-app": { "$ref": "#/$defs/github_app", - "description": "GitHub App configuration for token minting. When configured, a GitHub App installation access token is minted at workflow start and used instead of the default token. This token overrides any custom github-token setting and provides fine-grained permissions matching the agent job requirements.", - "examples": [ - { - "app-id": "${{ vars.APP_ID }}", - "private-key": "${{ secrets.APP_PRIVATE_KEY }}" - }, - { - "app-id": "${{ vars.APP_ID }}", - "private-key": "${{ secrets.APP_PRIVATE_KEY }}", - "repositories": ["repo1", "repo2"] - } - ] + "description": "GitHub App configuration for token minting. When configured, a GitHub App installation access token is minted at workflow start and used instead of the default token. This token overrides any custom github-token setting and provides fine-grained permissions matching the agent job requirements." } }, "additionalProperties": false, @@ -9677,6 +9567,10 @@ "items": { "type": "string" } + }, + "permissions": { + "$ref": "#/$defs/github_app_permissions", + "description": "Optional extra GitHub App-only permissions to merge into the minted token. Only takes effect for tools.github.github-app; ignored in other github-app contexts." } }, "required": ["app-id", "private-key"], @@ -9956,6 +9850,274 @@ "description": "GitHub App configuration used to mint a token for the search API request. Mutually exclusive with github-token." } } + }, + "github_actions_permissions": { + "type": "object", + "description": "Detailed permissions object with granular control over specific GitHub API scopes", + "additionalProperties": false, + "properties": { + "actions": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for GitHub Actions workflows and runs (read: view workflows, write: manage workflows, none: no access)" + }, + "attestations": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for artifact attestations (read: view attestations, write: create attestations, none: no access)" + }, + "checks": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for repository checks and status checks (read: view checks, write: create/update checks, none: no access)" + }, + "contents": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for repository contents (read: view files, write: modify files/branches, none: no access)" + }, + "deployments": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for repository deployments (read: view deployments, write: create/update deployments, none: no access)" + }, + "discussions": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for repository discussions (read: view discussions, write: create/update discussions, none: no access)" + }, + "id-token": { + "type": "string", + "enum": ["write", "none"], + "description": "Permission level for OIDC token requests (write/none only - read is not supported). Allows workflows to request JWT tokens for cloud provider authentication." + }, + "issues": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for repository issues (read: view issues, write: create/update/close issues, none: no access)" + }, + "models": { + "type": "string", + "enum": ["read", "none"], + "description": "Permission for GitHub Copilot models (read: access AI models for agentic workflows, none: no access)" + }, + "metadata": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission for repository metadata (read: view repository information, write: update repository metadata, none: no access)" + }, + "packages": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for GitHub Packages (read/write/none). Controls access to publish, modify, or delete packages." + }, + "pages": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for GitHub Pages (read/write/none). Controls access to deploy and manage GitHub Pages sites." + }, + "pull-requests": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for pull requests (read/write/none). Controls access to create, edit, review, and manage pull requests." + }, + "repository-projects": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for repository projects (read/write/none). Controls access to manage repository-level GitHub Projects boards." + }, + "organization-projects": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for organization projects (read/write/none). Controls access to manage organization-level GitHub Projects boards." + }, + "security-events": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for security events (read/write/none). Controls access to view and manage code scanning alerts and security findings." + }, + "statuses": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for commit statuses (read/write/none). Controls access to create and update commit status checks." + }, + "vulnerability-alerts": { + "type": "string", + "enum": ["read", "write", "none"], + "description": "Permission level for Dependabot vulnerability alerts (read/write/none). GitHub App-only permission: required to access Dependabot alerts via the GitHub MCP server. The GITHUB_TOKEN does not have this permission \u2014 a GitHub App must be configured." + }, + "all": { + "type": "string", + "enum": ["read"], + "description": "Permission shorthand that applies read access to all permission scopes. Can be combined with specific write permissions to override individual scopes. 'write' is not allowed for all." + } + } + }, + "github_app_permissions": { + "type": "object", + "description": "Optional extra GitHub App-only permissions to merge into the minted token. These are added on top of job-level permissions (nested wins). Use this for org-level permissions (e.g., members: read) that are not valid GitHub Actions scopes. Only \"read\" and \"none\" are allowed; specifying \"write\" is a compiler error.", + "additionalProperties": false, + "properties": { + "administration": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for repository administration (read/none; \"write\" is rejected by the compiler). GitHub App-only permission for repository administration." + }, + "codespaces": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for Codespaces (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "codespaces-lifecycle-admin": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for Codespaces lifecycle administration (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "codespaces-metadata": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for Codespaces metadata (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "email-addresses": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for user email addresses (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "environments": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for repository environments (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "git-signing": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for git signing (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "members": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization members (read/none; \"write\" is rejected by the compiler). Required for org team membership API calls." + }, + "organization-administration": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization administration (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-announcement-banners": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization announcement banners (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-codespaces": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization Codespaces (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-copilot": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization Copilot (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-custom-org-roles": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization custom org roles (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-custom-properties": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization custom properties (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-custom-repository-roles": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization custom repository roles (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-events": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization events (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-hooks": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization webhooks (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-members": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization members management (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-packages": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization packages (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-personal-access-token-requests": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization personal access token requests (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-personal-access-tokens": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization personal access tokens (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-plan": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization plan (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-self-hosted-runners": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization self-hosted runners (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "organization-user-blocking": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for organization user blocking (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "repository-custom-properties": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for repository custom properties (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "repository-hooks": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for repository webhooks (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "single-file": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for single file access (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "team-discussions": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for team discussions (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "vulnerability-alerts": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for Dependabot vulnerability alerts (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + }, + "workflows": { + "type": "string", + "enum": ["read", "none", "write"], + "description": "Permission level for GitHub Actions workflow files (read/none; \"write\" is rejected by the compiler). GitHub App-only permission." + } + }, + "examples": [ + { + "members": "read" + }, + { + "members": "read", + "organization-administration": "read" + } + ] } } } diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 109cc362698..395e3076d73 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -150,6 +150,15 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } + // Validate tools.github.github-app.permissions does not use "write" + log.Printf("Validating GitHub MCP app permissions (no write)") + if err := validateGitHubMCPAppPermissionsNoWrite(workflowData); err != nil { + return formatCompilerError(markdownPath, "error", err.Error(), err) + } + + // Warn when github-app.permissions is set in contexts that don't support it + warnGitHubAppPermissionsUnsupportedContexts(workflowData) + // 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/compiler_github_mcp_steps.go b/pkg/workflow/compiler_github_mcp_steps.go index de1199b4389..00bb19945fe 100644 --- a/pkg/workflow/compiler_github_mcp_steps.go +++ b/pkg/workflow/compiler_github_mcp_steps.go @@ -2,8 +2,10 @@ package workflow import ( "fmt" + "os" "strings" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" ) @@ -102,6 +104,26 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, d permissions = NewPermissions() } + // Apply extra permissions from github-app.permissions (nested wins over job-level) + if len(app.Permissions) > 0 { + githubConfigLog.Printf("Applying %d extra permissions from github-app.permissions", len(app.Permissions)) + for key, val := range app.Permissions { + scope := convertStringToPermissionScope(key) + if scope == "" { + msg := fmt.Sprintf("Unknown permission scope %q in tools.github.github-app.permissions. Valid scopes include: members, organization-administration, team-discussions, organization-members, administration, etc.", key) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(msg)) + continue + } + level := strings.ToLower(strings.TrimSpace(val)) + if level != string(PermissionRead) && level != string(PermissionNone) { + msg := fmt.Sprintf("Unknown permission level %q for scope %q in tools.github.github-app.permissions. Valid levels are: read, none.", val, key) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(msg)) + continue + } + permissions.Set(scope, PermissionLevel(level)) + } + } + // Generate the token minting step using the existing helper from safe_outputs_app.go steps := c.buildGitHubAppTokenMintStep(app, permissions, "") diff --git a/pkg/workflow/github_app_permissions_validation.go b/pkg/workflow/github_app_permissions_validation.go index 2158f7bceb4..c9fc228cbb3 100644 --- a/pkg/workflow/github_app_permissions_validation.go +++ b/pkg/workflow/github_app_permissions_validation.go @@ -2,8 +2,12 @@ package workflow import ( "errors" + "fmt" + "os" "sort" "strings" + + "github.com/github/gh-aw/pkg/console" ) var githubAppPermissionsLog = newValidationLogger("github_app_permissions") @@ -109,6 +113,101 @@ func formatWriteOnAppScopesError(scopes []PermissionScope) error { return errors.New(strings.Join(lines, "\n")) } +// validateGitHubMCPAppPermissionsNoWrite validates that every scope in +// tools.github.github-app.permissions is set to "read" or "none" (after trimming/lowercasing). +// The schema allows "write" so that editors can offer it as a completion, but +// the compiler must reject it because GitHub App-only scopes have no write-level +// semantics in this context — write operations must go through safe-outputs. +// Any other unrecognised level is also rejected here. +func validateGitHubMCPAppPermissionsNoWrite(workflowData *WorkflowData) error { + if workflowData.ParsedTools == nil || + workflowData.ParsedTools.GitHub == nil || + workflowData.ParsedTools.GitHub.GitHubApp == nil { + return nil + } + app := workflowData.ParsedTools.GitHub.GitHubApp + if len(app.Permissions) == 0 { + return nil + } + + var invalidScopes []string + var writeScopes []string + for scope, level := range app.Permissions { + normalized := strings.ToLower(strings.TrimSpace(level)) + switch normalized { + case string(PermissionRead), string(PermissionNone): + // valid + default: + invalidScopes = append(invalidScopes, scope+" (level: "+level+")") + if normalized == string(PermissionWrite) { + writeScopes = append(writeScopes, scope) + } + } + } + if len(invalidScopes) == 0 { + return nil + } + sort.Strings(invalidScopes) + sort.Strings(writeScopes) + + var lines []string + lines = append(lines, "Invalid permission levels in tools.github.github-app.permissions.") + lines = append(lines, "Each permission level must be exactly \"read\" or \"none\".") + if len(writeScopes) > 0 { + lines = append(lines, "") + lines = append(lines, `"write" is not allowed: write operations must be performed via safe-outputs.`) + lines = append(lines, "") + lines = append(lines, "The following scopes were declared with \"write\" access:") + lines = append(lines, "") + for _, s := range writeScopes { + lines = append(lines, " - "+s) + } + } + lines = append(lines, "") + lines = append(lines, "The following scopes have invalid permission levels:") + lines = append(lines, "") + for _, s := range invalidScopes { + lines = append(lines, " - "+s) + } + lines = append(lines, "") + lines = append(lines, "Change the permission level to \"read\" for read-only access, or \"none\" to disable the scope.") + return errors.New(strings.Join(lines, "\n")) +} + +// warnGitHubAppPermissionsUnsupportedContexts emits a warning when +// tools.github.github-app.permissions is set in contexts that do not support it. +// The permissions field only takes effect for the GitHub MCP token minting step; +// it is silently ignored if set on safe-outputs.github-app, on.github-app, or the +// top-level github-app fallback. +func warnGitHubAppPermissionsUnsupportedContexts(workflowData *WorkflowData) { + type context struct { + label string + app *GitHubAppConfig + } + unsupported := []context{ + {"safe-outputs.github-app", safeOutputsGitHubApp(workflowData)}, + {"on.github-app", workflowData.ActivationGitHubApp}, + {"github-app (top-level fallback)", workflowData.TopLevelGitHubApp}, + } + for _, ctx := range unsupported { + if ctx.app != nil && len(ctx.app.Permissions) > 0 { + msg := fmt.Sprintf( + "The 'permissions' field under '%s' has no effect. "+ + "Extra GitHub App permissions only apply to tools.github.github-app.", + ctx.label, + ) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(msg)) + } + } +} + +func safeOutputsGitHubApp(workflowData *WorkflowData) *GitHubAppConfig { + if workflowData.SafeOutputs == nil { + return nil + } + return workflowData.SafeOutputs.GitHubApp +} + func hasGitHubAppConfigured(workflowData *WorkflowData) bool { // Check tools.github.github-app if workflowData.ParsedTools != nil && diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index 609cd2d48f5..af0e86bcc6f 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -385,3 +385,147 @@ Test that permission-vulnerability-alerts is emitted in the App token minting st } } } + +// TestGitHubMCPAppTokenWithExtraPermissions tests that extra permissions under +// tools.github.github-app.permissions are merged into the minted token (nested wins). +// This allows org-level permissions (e.g. members: read) that are not valid GitHub +// Actions scopes but are supported by GitHub Apps. +func TestGitHubMCPAppTokenWithExtraPermissions(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read + issues: read +strict: false +tools: + github: + mode: local + toolsets: [orgs, users] + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + repositories: ["*"] + permissions: + members: read + organization-administration: read +--- + +# Test Workflow + +Test extra org-level permissions in GitHub App token. +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Failed to compile workflow") + + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // Verify the token minting step is present + assert.Contains(t, lockContent, "id: github-mcp-app-token", "GitHub App token step should be generated") + + // Verify that job-level permissions are still included + assert.Contains(t, lockContent, "permission-contents: read", "Should include job-level contents permission") + assert.Contains(t, lockContent, "permission-issues: read", "Should include job-level issues permission") + + // Verify that the extra org-level permissions from github-app.permissions are included + assert.Contains(t, lockContent, "permission-members: read", "Should include extra members permission from github-app.permissions") + assert.Contains(t, lockContent, "permission-organization-administration: read", "Should include extra organization-administration permission from github-app.permissions") +} + +// TestGitHubMCPAppTokenExtraPermissionsOverrideJobLevel tests that extra permissions +// under tools.github.github-app.permissions can suppress a GitHub App-only scope +// that was set at job level by overriding it with 'none' (nested wins). +func TestGitHubMCPAppTokenExtraPermissionsOverrideJobLevel(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read + issues: read + vulnerability-alerts: read +strict: false +tools: + github: + mode: local + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permissions: + vulnerability-alerts: none +--- + +# Test Workflow + +Test that nested permissions override job-level GitHub App-only scopes (nested wins). +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Failed to compile workflow") + + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // The nested permission (none) should win over the job-level permission (read) + assert.Contains(t, lockContent, "permission-vulnerability-alerts: none", "Nested vulnerability-alerts: none should override job-level: read") + assert.NotContains(t, lockContent, "permission-vulnerability-alerts: read", "Job-level vulnerability-alerts: read should be overridden by nested none") + + // Other job-level permissions should still be present + assert.Contains(t, lockContent, "permission-contents: read", "Unaffected job-level contents permission should still be present") + assert.Contains(t, lockContent, "permission-issues: read", "Unaffected job-level issues permission should still be present") +} + +// TestGitHubMCPAppTokenExtraPermissionsWriteRejected tests that the compiler +// rejects a workflow where tools.github.github-app.permissions contains a "write" +// value, since write access is not allowed for GitHub App-only scopes in this section. +func TestGitHubMCPAppTokenExtraPermissionsWriteRejected(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +strict: false +tools: + github: + mode: local + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permissions: + members: write +--- + +# Test Workflow + +Test that write is rejected in tools.github.github-app.permissions. +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + err = compiler.CompileWorkflow(testFile) + require.Error(t, err, "Compiler should reject write in tools.github.github-app.permissions") + assert.Contains(t, err.Error(), "Invalid permission levels in tools.github.github-app.permissions", "Error should mention invalid permission levels") + assert.Contains(t, err.Error(), `"write" is not allowed`, "Error should mention that write is not allowed") + assert.Contains(t, err.Error(), "members", "Error should mention the offending scope") +} diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index 92b7bbddf7f..dff060e66f0 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -16,10 +16,11 @@ var safeOutputsAppLog = logger.New("workflow:safe_outputs_app") // GitHubAppConfig holds configuration for GitHub App-based token minting type GitHubAppConfig struct { - AppID string `yaml:"app-id,omitempty"` // GitHub App ID (e.g., "${{ vars.APP_ID }}") - PrivateKey string `yaml:"private-key,omitempty"` // GitHub App private key (e.g., "${{ secrets.APP_PRIVATE_KEY }}") - Owner string `yaml:"owner,omitempty"` // Optional: owner of the GitHub App installation (defaults to current repository owner) - Repositories []string `yaml:"repositories,omitempty"` // Optional: comma or newline-separated list of repositories to grant access to + AppID string `yaml:"app-id,omitempty"` // GitHub App ID (e.g., "${{ vars.APP_ID }}") + PrivateKey string `yaml:"private-key,omitempty"` // GitHub App private key (e.g., "${{ secrets.APP_PRIVATE_KEY }}") + Owner string `yaml:"owner,omitempty"` // Optional: owner of the GitHub App installation (defaults to current repository owner) + Repositories []string `yaml:"repositories,omitempty"` // Optional: comma or newline-separated list of repositories to grant access to + Permissions map[string]string `yaml:"permissions,omitempty"` // Optional: extra permission-* fields to merge into the minted token (nested wins over job-level) } // ======================================== @@ -65,6 +66,22 @@ func parseAppConfig(appMap map[string]any) *GitHubAppConfig { } } + // Parse permissions (optional) - extra permission-* fields to merge into the minted token + if perms, exists := appMap["permissions"]; exists { + if permsMap, ok := perms.(map[string]any); ok { + appConfig.Permissions = make(map[string]string, len(permsMap)) + for key, val := range permsMap { + if valStr, ok := val.(string); ok { + appConfig.Permissions[key] = valStr + } else { + safeOutputsAppLog.Printf("Ignoring github-app.permissions[%q]: expected string value, got %T", key, val) + } + } + } else { + safeOutputsAppLog.Printf("Ignoring github-app.permissions: expected object, got %T", perms) + } + } + return appConfig }