diff --git a/.changeset/patch-dependabot-permission-checks.md b/.changeset/patch-dependabot-permission-checks.md new file mode 100644 index 0000000000..46cca46c7f --- /dev/null +++ b/.changeset/patch-dependabot-permission-checks.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Ensure the dependabot toolset automatically injects the `security-events: read` permission and validates it in the smoke-codex workflow so dependabot tooling has the required access. diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 0e5f7f0dda..4a5942e7c6 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -28,7 +28,7 @@ # - shared/gh.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"fbb9ed29477ed621b1c3827b01a8f7bf0052063618b2900bb1e739453a3aa770"} +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"59dfabec6f64b9a1721960f5ae83afe09d32dfeed26e685bd773fbd0ad9f7512"} name: "Smoke Codex" "on": @@ -274,6 +274,7 @@ jobs: contents: read issues: read pull-requests: read + security-events: read env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" @@ -1045,7 +1046,7 @@ jobs: startup_timeout_sec = 120 tool_timeout_sec = 60 container = "ghcr.io/github/github-mcp-server:v0.31.0" - env = { "GITHUB_PERSONAL_ACCESS_TOKEN" = "$GH_AW_GITHUB_TOKEN", "GITHUB_READ_ONLY" = "1", "GITHUB_TOOLSETS" = "context,repos,issues,pull_requests" } + env = { "GITHUB_PERSONAL_ACCESS_TOKEN" = "$GH_AW_GITHUB_TOKEN", "GITHUB_READ_ONLY" = "1", "GITHUB_TOOLSETS" = "context,repos,issues,pull_requests,dependabot" } env_vars = ["GITHUB_PERSONAL_ACCESS_TOKEN", "GITHUB_READ_ONLY", "GITHUB_TOOLSETS"] [mcp_servers.playwright] @@ -1106,7 +1107,7 @@ jobs: "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests,dependabot" } }, "playwright": { diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index 3da0d8c2b0..c5827a13ff 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -12,6 +12,7 @@ permissions: contents: read issues: read pull-requests: read + security-events: read name: Smoke Codex engine: codex strict: true @@ -26,6 +27,7 @@ network: tools: cache-memory: true github: + toolsets: [default, dependabot] playwright: edit: bash: @@ -83,6 +85,7 @@ timeout-minutes: 15 5. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-codex-${{ github.run_id }}.txt` with content "Smoke test passed for Codex at $(date)" (create the directory if it doesn't exist) 6. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back) 7. **Build gh-aw**: Run `GOCACHE=/tmp/go-cache GOMODCACHE=/tmp/go-mod make build` to verify the agent can successfully build the gh-aw project (both caches must be set to /tmp because the default cache locations are not writable). If the command fails, mark this test as ❌ and report the failure. +8. **Dependabot Testing**: Use the GitHub MCP `list_dependabot_alerts` tool to list up to 1 Dependabot alert from ${{ github.repository }}. An empty result is acceptable — just record whether any alerts were returned. ## Output diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index 44348fe411..4921420475 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -937,17 +937,34 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( // Agent job ALWAYS needs contents: read to access .github and .actions folders permissions := data.Permissions if permissions == "" { - // No permissions specified, just add contents: read + // No permissions specified, start with contents: read perms := NewPermissionsContentsRead() + // Add security-events: read if the dependabot toolset is configured + if isDependabotToolsetEnabled(data) { + perms.Set(PermissionSecurityEvents, PermissionRead) + } permissions = perms.RenderToYAML() } else { - // Parse existing permissions and add contents: read + // Parse existing permissions and ensure required scopes are present parser := NewPermissionsParser(permissions) perms := parser.ToPermissions() + modified := false // Only add contents: read if not already present if level, exists := perms.Get(PermissionContents); !exists || level == PermissionNone { perms.Set(PermissionContents, PermissionRead) + modified = true + } + + // Add security-events: read if the dependabot toolset is configured and not already set + if isDependabotToolsetEnabled(data) { + if _, exists := perms.Get(PermissionSecurityEvents); !exists { + perms.Set(PermissionSecurityEvents, PermissionRead) + modified = true + } + } + + if modified { permissions = perms.RenderToYAML() } } diff --git a/pkg/workflow/permissions_validator_test.go b/pkg/workflow/permissions_validator_test.go index b060908612..85d206fd23 100644 --- a/pkg/workflow/permissions_validator_test.go +++ b/pkg/workflow/permissions_validator_test.go @@ -72,6 +72,22 @@ func TestCollectRequiredPermissions(t *testing.T) { PermissionActions: PermissionRead, }, }, + { + name: "Dependabot toolset requires security-events read", + toolsets: []string{"dependabot"}, + readOnly: false, + expected: map[PermissionScope]PermissionLevel{ + PermissionSecurityEvents: PermissionRead, + }, + }, + { + name: "Dependabot toolset in read-only mode requires security-events read", + toolsets: []string{"dependabot"}, + readOnly: true, + expected: map[PermissionScope]PermissionLevel{ + PermissionSecurityEvents: PermissionRead, + }, + }, { name: "Code security toolset", toolsets: []string{"code_security"}, @@ -384,3 +400,105 @@ func TestValidatePermissions_ComplexScenarios(t *testing.T) { }) } } + +func TestInjectDependabotPermission(t *testing.T) { + tests := []struct { + name string + initialPermissions string + toolsets GitHubToolsets + expectSecEvents PermissionLevel + expectInjected bool + }{ + { + name: "Injects security-events: read when dependabot toolset configured and no permissions set", + initialPermissions: "", + toolsets: GitHubToolsets{"dependabot"}, + expectSecEvents: PermissionRead, + expectInjected: true, + }, + { + name: "Injects security-events: read when dependabot is among multiple toolsets", + initialPermissions: "permissions:\n contents: read", + toolsets: GitHubToolsets{"default", "dependabot"}, + expectSecEvents: PermissionRead, + expectInjected: true, + }, + { + name: "Does not inject when security-events already set to read", + initialPermissions: "permissions:\n security-events: read", + toolsets: GitHubToolsets{"dependabot"}, + expectSecEvents: PermissionRead, + expectInjected: false, + }, + { + name: "Does not inject when security-events already set to write", + initialPermissions: "permissions:\n security-events: write", + toolsets: GitHubToolsets{"dependabot"}, + expectSecEvents: PermissionWrite, + expectInjected: false, + }, + { + name: "Does not inject when security-events explicitly set to none", + initialPermissions: "permissions:\n security-events: none", + toolsets: GitHubToolsets{"dependabot"}, + expectSecEvents: PermissionNone, + expectInjected: false, + }, + { + name: "Does not inject when dependabot toolset is not configured", + initialPermissions: "permissions:\n contents: read", + toolsets: GitHubToolsets{"repos"}, + expectSecEvents: "", + expectInjected: false, + }, + { + name: "Does not inject when no GitHub tool is configured", + initialPermissions: "permissions:\n contents: read", + toolsets: nil, + expectSecEvents: "", + expectInjected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := &WorkflowData{ + Permissions: tt.initialPermissions, + } + if tt.toolsets != nil { + data.ParsedTools = &Tools{ + GitHub: &GitHubToolConfig{ + Toolset: tt.toolsets, + }, + } + } + + injectDependabotPermission(data) + + perms := NewPermissionsParser(data.Permissions).ToPermissions() + level, exists := perms.Get(PermissionSecurityEvents) + + if tt.expectInjected { + if !exists { + t.Errorf("Expected security-events permission to be injected, but not found in: %q", data.Permissions) + return + } + if level != tt.expectSecEvents { + t.Errorf("Expected security-events: %s, got: %s", tt.expectSecEvents, level) + } + } else if tt.expectSecEvents == "" { + if exists { + t.Errorf("Expected security-events NOT to be present, but found: %s", level) + } + } else { + if !exists { + t.Errorf("Expected security-events: %s to be preserved, but permission not found", tt.expectSecEvents) + return + } + if level != tt.expectSecEvents { + t.Errorf("Expected security-events: %s to be preserved, got: %s", tt.expectSecEvents, level) + } + } + }) + } +} diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index 06021b1c0d..80b292014e 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "os" + "slices" "strings" "time" @@ -181,9 +182,55 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error } data.Permissions = strings.Join(lines, "\n") } + + // Inject security-events: read when the dependabot toolset is configured but + // the permission has not been declared. This ensures the workflow automatically + // receives the minimum permission needed to access Dependabot APIs. + injectDependabotPermission(data) + return nil } +// isDependabotToolsetEnabled returns true when the dependabot toolset is effectively +// configured for the workflow (including when it is implied by the "all" alias). +func isDependabotToolsetEnabled(data *WorkflowData) bool { + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil { + return false + } + toolsets := ParseGitHubToolsets(data.ParsedTools.GitHub.GetToolsets()) + return slices.Contains(toolsets, "dependabot") +} + +// injectDependabotPermission adds security-events: read to data.Permissions when the +// dependabot toolset is enabled and the permission has not already been declared. +// It respects any explicitly set level (read, write, or none) and does not override it. +func injectDependabotPermission(data *WorkflowData) { + if !isDependabotToolsetEnabled(data) { + return + } + + // Parse current permissions and check if security-events is already declared. + parser := NewPermissionsParser(data.Permissions) + perms := parser.ToPermissions() + if _, exists := perms.Get(PermissionSecurityEvents); exists { + // User has explicitly set security-events (read, write, or none) – respect it. + return + } + + // Inject security-events: read. + perms.Set(PermissionSecurityEvents, PermissionRead) + rendered := perms.RenderToYAML() + + // RenderToYAML uses 6-space indentation; data.Permissions uses 2-space. + lines := strings.Split(rendered, "\n") + for i := 1; i < len(lines); i++ { + if strings.HasPrefix(lines[i], " ") { + lines[i] = " " + lines[i][6:] + } + } + data.Permissions = strings.Join(lines, "\n") +} + // mergeToolsAndMCPServers merges tools, mcp-servers, and included tools func (c *Compiler) mergeToolsAndMCPServers(topTools, mcpServers map[string]any, includedTools string) (map[string]any, error) { toolsLog.Printf("Merging tools and MCP servers: topTools=%d, mcpServers=%d", len(topTools), len(mcpServers)) diff --git a/smoke-test-22330477648.txt b/smoke-test-22330477648.txt new file mode 100644 index 0000000000..090ed2d9c4 --- /dev/null +++ b/smoke-test-22330477648.txt @@ -0,0 +1,2 @@ +Smoke test file - Run 22330477648 +Created by Claude smoke test agent on Tue Feb 24 00:15:09 UTC 2026