From 9e12f15cd52ddaef44df9070e16692c91b890107 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:37:14 +0000 Subject: [PATCH 1/3] Initial plan From 4ff07f17f735d63d9f09876de2a840adc5aa8384 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:03:06 +0000 Subject: [PATCH 2/3] fix: move vulnerability-alerts permission to workflow level in compiled YAML The GitHub Actions engine rejects `vulnerability-alerts: read` when it appears in a job-level permissions block. It must be declared at the workflow level. Changes: - Add `GetWorkflowOnlyPermissionScopes()` and `IsWorkflowOnlyPermissionScope()` to identify permissions that can only be used at the workflow level - Add `Delete()` method to `Permissions` struct - Modify `filterJobLevelPermissions()` to strip workflow-only scopes from job-level permissions blocks - Add `buildWorkflowLevelPermissions()` to generate a workflow-level permissions block that includes workflow-only scopes - Modify `generateWorkflowBody()` to use `buildWorkflowLevelPermissions()` instead of always writing `permissions: {}` - Update tests to reflect new behavior - Recompile dependabot-go-checker.lock.yml with fix applied Fixes: vulnerability-alerts: read permission not allowed at job level Agent-Logs-Url: https://github.com/github/gh-aw/sessions/acbdaa27-6741-4f27-b710-dfefd460df44 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/dependabot-go-checker.lock.yml | 4 +- pkg/workflow/compiler_yaml.go | 9 ++- pkg/workflow/github_mcp_app_token_test.go | 20 ++++--- pkg/workflow/permissions.go | 15 +++++ pkg/workflow/permissions_operations.go | 59 +++++++++++++++++++ pkg/workflow/permissions_operations_test.go | 10 ++-- pkg/workflow/schema_validation_test.go | 6 +- 7 files changed, 101 insertions(+), 22 deletions(-) diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 0f810045777..0de6c5693ad 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -63,7 +63,8 @@ name: "Dependabot Dependency Checker" required: false type: string -permissions: {} +permissions: + vulnerability-alerts: read concurrency: group: "gh-aw-${{ github.workflow }}" @@ -305,7 +306,6 @@ jobs: issues: read pull-requests: read security-events: read - vulnerability-alerts: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" env: diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 3e5b5bb8d31..09981b90d2e 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -273,9 +273,12 @@ func (c *Compiler) generateWorkflowBody(yaml *strings.Builder, data *WorkflowDat // Note: GitHub Actions doesn't support workflow-level if conditions // The workflow_run safety check is added to individual jobs instead - // Always write empty permissions at the top level - // Agent permissions are applied only to the agent job - yaml.WriteString("permissions: {}\n\n") + // Build workflow-level permissions block. + // Workflow-only scopes (e.g. vulnerability-alerts) must be declared here because the + // GitHub Actions engine rejects them when placed inside a job-level permissions block. + // All other permissions are applied only to the agent job. + workflowLevelPermsBlock := buildWorkflowLevelPermissions(data.Permissions) + yaml.WriteString(workflowLevelPermsBlock + "\n\n") yaml.WriteString(data.Concurrency + "\n\n") yaml.WriteString(data.RunName + "\n\n") diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index e2db85d21bf..78e0ee82d70 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -367,14 +367,20 @@ Test that permission-vulnerability-alerts is emitted in the App token minting st assert.Contains(t, lockContent, "permission-security-events: read", "Should also include security-events read permission in App token") // 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 vulnerability-alerts DOES appear in job-level permissions block. - // It is now a valid GITHUB_TOKEN permission scope. + // Verify that vulnerability-alerts appears at the WORKFLOW level, not at the job level. + // The GitHub Actions engine rejects vulnerability-alerts in job-level permissions blocks. var workflow map[string]any require.NoError(t, goyaml.Unmarshal(content, &workflow), "Lock file should be valid YAML") + wfPerms, hasWfPerms := workflow["permissions"] + require.True(t, hasWfPerms, "Workflow should have a top-level permissions block") + wfPermsMap, ok := wfPerms.(map[string]any) + require.True(t, ok, "Workflow-level permissions should be a map") + _, foundVulnAlertsAtWfLevel := wfPermsMap["vulnerability-alerts"] + assert.True(t, foundVulnAlertsAtWfLevel, "vulnerability-alerts should appear in the workflow-level permissions block (not job-level)") + // Verify it does NOT appear in any job-level permissions block jobs, ok := workflow["jobs"].(map[string]any) require.True(t, ok, "Should have jobs section") - foundVulnAlerts := false - for _, jobConfig := range jobs { + for jobName, jobConfig := range jobs { jobMap, ok := jobConfig.(map[string]any) if !ok { continue @@ -387,11 +393,9 @@ Test that permission-vulnerability-alerts is emitted in the App token minting st if !ok { continue } - if _, found := permsMap["vulnerability-alerts"]; found { - foundVulnAlerts = true - } + _, foundAtJobLevel := permsMap["vulnerability-alerts"] + assert.False(t, foundAtJobLevel, "vulnerability-alerts must NOT appear in job-level permissions (job: %s)", jobName) } - assert.True(t, foundVulnAlerts, "vulnerability-alerts should appear in at least one job-level permissions block (it is a GITHUB_TOKEN scope)") } // TestGitHubMCPAppTokenWithExtraPermissions tests that extra permissions under diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 8b5626ad1cc..178ac7d9487 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -279,6 +279,21 @@ func IsGitHubAppOnlyScope(scope PermissionScope) bool { return isAppOnly } +// GetWorkflowOnlyPermissionScopes returns GITHUB_TOKEN permission scopes that are only +// valid at the workflow level and cannot be used in job-level permissions blocks. +// The GitHub Actions engine rejects these scopes when placed inside a job's permissions. +func GetWorkflowOnlyPermissionScopes() []PermissionScope { + return []PermissionScope{ + PermissionVulnerabilityAlerts, + } +} + +// IsWorkflowOnlyPermissionScope returns true if the scope is a GITHUB_TOKEN permission +// that is only valid at the workflow level, not at the job level. +func IsWorkflowOnlyPermissionScope(scope PermissionScope) bool { + return slices.Contains(GetWorkflowOnlyPermissionScopes(), 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 198f0ed858f..0debc58e709 100644 --- a/pkg/workflow/permissions_operations.go +++ b/pkg/workflow/permissions_operations.go @@ -40,6 +40,13 @@ func filterJobLevelPermissions(rawPermissionsYAML string) string { } filtered := NewPermissionsParser(rawPermissionsYAML).ToPermissions() + + // Remove scopes that are workflow-level-only and must not appear in job-level blocks. + // The GitHub Actions engine rejects these scopes at the job level. + for _, scope := range GetWorkflowOnlyPermissionScopes() { + filtered.Delete(scope) + } + rendered := filtered.RenderToYAML() if rendered == "" { // If the raw permissions YAML was an explicit empty block (permissions: {}), preserve @@ -71,6 +78,58 @@ func filterJobLevelPermissions(rawPermissionsYAML string) string { return strings.Join(lines, "\n") } +// buildWorkflowLevelPermissions returns a YAML permissions block for the workflow level. +// It includes only the workflow-only scopes (e.g. vulnerability-alerts) extracted from +// the raw permissions YAML. All other scopes are handled at the job level. +// Returns "permissions: {}" when no workflow-only scopes are present, so the workflow +// always declares an explicit empty top-level permissions block that prevents GitHub +// Actions from granting the default write-all token permissions. +func buildWorkflowLevelPermissions(rawPermissionsYAML string) string { + if rawPermissionsYAML == "" { + return "permissions: {}" + } + + parsed := NewPermissionsParser(rawPermissionsYAML).ToPermissions() + + // Collect only the workflow-level-only scopes that the user has explicitly set. + wfPerms := NewPermissions() + for _, scope := range GetWorkflowOnlyPermissionScopes() { + if level, exists := parsed.GetExplicit(scope); exists { + wfPerms.Set(scope, level) + } + } + + rendered := wfPerms.RenderToYAML() + if rendered == "" { + return "permissions: {}" + } + + // RenderToYAML uses 6-space indentation for permission values; normalise to 2 spaces + // so the workflow-level block looks like: + // permissions: + // vulnerability-alerts: read + const renderYAMLIndent = 6 + const targetIndent = 2 + prefix := strings.Repeat(" ", renderYAMLIndent) + replacement := strings.Repeat(" ", targetIndent) + lines := strings.Split(rendered, "\n") + for i := 1; i < len(lines); i++ { + if strings.HasPrefix(lines[i], prefix) { + lines[i] = replacement + lines[i][renderYAMLIndent:] + } + } + return strings.Join(lines, "\n") +} + +// Delete removes a specific scope from the permissions map. +// This has no effect if the permission is set via shorthand or hasAll. +func (p *Permissions) Delete(scope PermissionScope) { + if p == nil || p.permissions == nil { + return + } + delete(p.permissions, scope) +} + // Set sets a permission for a specific scope func (p *Permissions) Set(scope PermissionScope, level PermissionLevel) { permissionsOpsLog.Printf("Setting permission: scope=%s, level=%s", scope, level) diff --git a/pkg/workflow/permissions_operations_test.go b/pkg/workflow/permissions_operations_test.go index 43021b08607..081e10dfdc6 100644 --- a/pkg/workflow/permissions_operations_test.go +++ b/pkg/workflow/permissions_operations_test.go @@ -706,27 +706,25 @@ func TestFilterJobLevelPermissions(t *testing.T) { excludes: []string{}, }, { - name: "vulnerability-alerts is preserved (GITHUB_TOKEN scope)", + name: "vulnerability-alerts is moved to workflow level (not in job-level output)", input: "permissions:\n contents: read\n pull-requests: read\n security-events: read\n vulnerability-alerts: read", contains: []string{ "permissions:", " contents: read", " pull-requests: read", " security-events: read", - " vulnerability-alerts: read", }, - excludes: []string{}, + excludes: []string{"vulnerability-alerts"}, }, { - name: "multiple GitHub App-only scopes are filtered out but vulnerability-alerts is preserved", + name: "multiple GitHub App-only scopes are filtered out and vulnerability-alerts moved to workflow level", input: "permissions:\n contents: read\n issues: write\n administration: read\n members: read\n vulnerability-alerts: read", contains: []string{ "permissions:", " contents: read", " issues: write", - " vulnerability-alerts: read", }, - excludes: []string{"administration", "members"}, + excludes: []string{"administration", "members", "vulnerability-alerts"}, }, { name: "only GitHub App-only scopes returns empty string", diff --git a/pkg/workflow/schema_validation_test.go b/pkg/workflow/schema_validation_test.go index 27946f84074..0f30fca51e3 100644 --- a/pkg/workflow/schema_validation_test.go +++ b/pkg/workflow/schema_validation_test.go @@ -214,14 +214,14 @@ jobs: expectError: false, }, { - name: "vulnerability-alerts permission in job permissions passes", + name: "vulnerability-alerts permission in workflow-level permissions passes", yamlContent: ` name: Test on: push +permissions: + vulnerability-alerts: read jobs: test: - permissions: - vulnerability-alerts: read runs-on: ubuntu-latest steps: - run: echo hello From 3229fdba197c25c7dc99a7300d7c677b54124450 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:13:46 +0000 Subject: [PATCH 3/3] revert: keep vulnerability-alerts at job level, schema already supports it The GitHub Actions JSON schema (main_workflow_schema.json) already includes vulnerability-alerts in github_actions_permissions with additionalProperties:false, so the permission is valid at the job level. Revert the previous approach of moving it to workflow level and keep vulnerability-alerts: read in the agent job permissions block where it belongs. Recompile dependabot-go-checker.lock.yml accordingly. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/79ffb5f7-2910-4153-a0ba-52d4378eec1d Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/dependabot-go-checker.lock.yml | 4 +- pkg/workflow/compiler_yaml.go | 9 +-- pkg/workflow/github_mcp_app_token_test.go | 20 +++---- pkg/workflow/permissions.go | 15 ----- pkg/workflow/permissions_operations.go | 59 ------------------- pkg/workflow/permissions_operations_test.go | 10 ++-- pkg/workflow/schema_validation_test.go | 6 +- 7 files changed, 22 insertions(+), 101 deletions(-) diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index 0de6c5693ad..0f810045777 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -63,8 +63,7 @@ name: "Dependabot Dependency Checker" required: false type: string -permissions: - vulnerability-alerts: read +permissions: {} concurrency: group: "gh-aw-${{ github.workflow }}" @@ -306,6 +305,7 @@ jobs: issues: read pull-requests: read security-events: read + vulnerability-alerts: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" env: diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 09981b90d2e..3e5b5bb8d31 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -273,12 +273,9 @@ func (c *Compiler) generateWorkflowBody(yaml *strings.Builder, data *WorkflowDat // Note: GitHub Actions doesn't support workflow-level if conditions // The workflow_run safety check is added to individual jobs instead - // Build workflow-level permissions block. - // Workflow-only scopes (e.g. vulnerability-alerts) must be declared here because the - // GitHub Actions engine rejects them when placed inside a job-level permissions block. - // All other permissions are applied only to the agent job. - workflowLevelPermsBlock := buildWorkflowLevelPermissions(data.Permissions) - yaml.WriteString(workflowLevelPermsBlock + "\n\n") + // Always write empty permissions at the top level + // Agent permissions are applied only to the agent job + yaml.WriteString("permissions: {}\n\n") yaml.WriteString(data.Concurrency + "\n\n") yaml.WriteString(data.RunName + "\n\n") diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index 78e0ee82d70..e2db85d21bf 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -367,20 +367,14 @@ Test that permission-vulnerability-alerts is emitted in the App token minting st assert.Contains(t, lockContent, "permission-security-events: read", "Should also include security-events read permission in App token") // 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 vulnerability-alerts appears at the WORKFLOW level, not at the job level. - // The GitHub Actions engine rejects vulnerability-alerts in job-level permissions blocks. + // Verify that vulnerability-alerts DOES appear in job-level permissions block. + // It is now a valid GITHUB_TOKEN permission scope. var workflow map[string]any require.NoError(t, goyaml.Unmarshal(content, &workflow), "Lock file should be valid YAML") - wfPerms, hasWfPerms := workflow["permissions"] - require.True(t, hasWfPerms, "Workflow should have a top-level permissions block") - wfPermsMap, ok := wfPerms.(map[string]any) - require.True(t, ok, "Workflow-level permissions should be a map") - _, foundVulnAlertsAtWfLevel := wfPermsMap["vulnerability-alerts"] - assert.True(t, foundVulnAlertsAtWfLevel, "vulnerability-alerts should appear in the workflow-level permissions block (not job-level)") - // Verify it does NOT appear in any job-level permissions block jobs, ok := workflow["jobs"].(map[string]any) require.True(t, ok, "Should have jobs section") - for jobName, jobConfig := range jobs { + foundVulnAlerts := false + for _, jobConfig := range jobs { jobMap, ok := jobConfig.(map[string]any) if !ok { continue @@ -393,9 +387,11 @@ Test that permission-vulnerability-alerts is emitted in the App token minting st if !ok { continue } - _, foundAtJobLevel := permsMap["vulnerability-alerts"] - assert.False(t, foundAtJobLevel, "vulnerability-alerts must NOT appear in job-level permissions (job: %s)", jobName) + if _, found := permsMap["vulnerability-alerts"]; found { + foundVulnAlerts = true + } } + assert.True(t, foundVulnAlerts, "vulnerability-alerts should appear in at least one job-level permissions block (it is a GITHUB_TOKEN scope)") } // TestGitHubMCPAppTokenWithExtraPermissions tests that extra permissions under diff --git a/pkg/workflow/permissions.go b/pkg/workflow/permissions.go index 178ac7d9487..8b5626ad1cc 100644 --- a/pkg/workflow/permissions.go +++ b/pkg/workflow/permissions.go @@ -279,21 +279,6 @@ func IsGitHubAppOnlyScope(scope PermissionScope) bool { return isAppOnly } -// GetWorkflowOnlyPermissionScopes returns GITHUB_TOKEN permission scopes that are only -// valid at the workflow level and cannot be used in job-level permissions blocks. -// The GitHub Actions engine rejects these scopes when placed inside a job's permissions. -func GetWorkflowOnlyPermissionScopes() []PermissionScope { - return []PermissionScope{ - PermissionVulnerabilityAlerts, - } -} - -// IsWorkflowOnlyPermissionScope returns true if the scope is a GITHUB_TOKEN permission -// that is only valid at the workflow level, not at the job level. -func IsWorkflowOnlyPermissionScope(scope PermissionScope) bool { - return slices.Contains(GetWorkflowOnlyPermissionScopes(), 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 0debc58e709..198f0ed858f 100644 --- a/pkg/workflow/permissions_operations.go +++ b/pkg/workflow/permissions_operations.go @@ -40,13 +40,6 @@ func filterJobLevelPermissions(rawPermissionsYAML string) string { } filtered := NewPermissionsParser(rawPermissionsYAML).ToPermissions() - - // Remove scopes that are workflow-level-only and must not appear in job-level blocks. - // The GitHub Actions engine rejects these scopes at the job level. - for _, scope := range GetWorkflowOnlyPermissionScopes() { - filtered.Delete(scope) - } - rendered := filtered.RenderToYAML() if rendered == "" { // If the raw permissions YAML was an explicit empty block (permissions: {}), preserve @@ -78,58 +71,6 @@ func filterJobLevelPermissions(rawPermissionsYAML string) string { return strings.Join(lines, "\n") } -// buildWorkflowLevelPermissions returns a YAML permissions block for the workflow level. -// It includes only the workflow-only scopes (e.g. vulnerability-alerts) extracted from -// the raw permissions YAML. All other scopes are handled at the job level. -// Returns "permissions: {}" when no workflow-only scopes are present, so the workflow -// always declares an explicit empty top-level permissions block that prevents GitHub -// Actions from granting the default write-all token permissions. -func buildWorkflowLevelPermissions(rawPermissionsYAML string) string { - if rawPermissionsYAML == "" { - return "permissions: {}" - } - - parsed := NewPermissionsParser(rawPermissionsYAML).ToPermissions() - - // Collect only the workflow-level-only scopes that the user has explicitly set. - wfPerms := NewPermissions() - for _, scope := range GetWorkflowOnlyPermissionScopes() { - if level, exists := parsed.GetExplicit(scope); exists { - wfPerms.Set(scope, level) - } - } - - rendered := wfPerms.RenderToYAML() - if rendered == "" { - return "permissions: {}" - } - - // RenderToYAML uses 6-space indentation for permission values; normalise to 2 spaces - // so the workflow-level block looks like: - // permissions: - // vulnerability-alerts: read - const renderYAMLIndent = 6 - const targetIndent = 2 - prefix := strings.Repeat(" ", renderYAMLIndent) - replacement := strings.Repeat(" ", targetIndent) - lines := strings.Split(rendered, "\n") - for i := 1; i < len(lines); i++ { - if strings.HasPrefix(lines[i], prefix) { - lines[i] = replacement + lines[i][renderYAMLIndent:] - } - } - return strings.Join(lines, "\n") -} - -// Delete removes a specific scope from the permissions map. -// This has no effect if the permission is set via shorthand or hasAll. -func (p *Permissions) Delete(scope PermissionScope) { - if p == nil || p.permissions == nil { - return - } - delete(p.permissions, scope) -} - // Set sets a permission for a specific scope func (p *Permissions) Set(scope PermissionScope, level PermissionLevel) { permissionsOpsLog.Printf("Setting permission: scope=%s, level=%s", scope, level) diff --git a/pkg/workflow/permissions_operations_test.go b/pkg/workflow/permissions_operations_test.go index 081e10dfdc6..43021b08607 100644 --- a/pkg/workflow/permissions_operations_test.go +++ b/pkg/workflow/permissions_operations_test.go @@ -706,25 +706,27 @@ func TestFilterJobLevelPermissions(t *testing.T) { excludes: []string{}, }, { - name: "vulnerability-alerts is moved to workflow level (not in job-level output)", + name: "vulnerability-alerts is preserved (GITHUB_TOKEN scope)", input: "permissions:\n contents: read\n pull-requests: read\n security-events: read\n vulnerability-alerts: read", contains: []string{ "permissions:", " contents: read", " pull-requests: read", " security-events: read", + " vulnerability-alerts: read", }, - excludes: []string{"vulnerability-alerts"}, + excludes: []string{}, }, { - name: "multiple GitHub App-only scopes are filtered out and vulnerability-alerts moved to workflow level", + name: "multiple GitHub App-only scopes are filtered out but vulnerability-alerts is preserved", input: "permissions:\n contents: read\n issues: write\n administration: read\n members: read\n vulnerability-alerts: read", contains: []string{ "permissions:", " contents: read", " issues: write", + " vulnerability-alerts: read", }, - excludes: []string{"administration", "members", "vulnerability-alerts"}, + excludes: []string{"administration", "members"}, }, { name: "only GitHub App-only scopes returns empty string", diff --git a/pkg/workflow/schema_validation_test.go b/pkg/workflow/schema_validation_test.go index 0f30fca51e3..27946f84074 100644 --- a/pkg/workflow/schema_validation_test.go +++ b/pkg/workflow/schema_validation_test.go @@ -214,14 +214,14 @@ jobs: expectError: false, }, { - name: "vulnerability-alerts permission in workflow-level permissions passes", + name: "vulnerability-alerts permission in job permissions passes", yamlContent: ` name: Test on: push -permissions: - vulnerability-alerts: read jobs: test: + permissions: + vulnerability-alerts: read runs-on: ubuntu-latest steps: - run: echo hello