Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-dependabot-permission-checks.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions .github/workflows/smoke-codex.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions .github/workflows/smoke-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ permissions:
contents: read
issues: read
pull-requests: read
security-events: read
name: Smoke Codex
engine: codex
strict: true
Expand All @@ -26,6 +27,7 @@ network:
tools:
cache-memory: true
github:
toolsets: [default, dependabot]
playwright:
edit:
bash:
Expand Down Expand Up @@ -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

Expand Down
21 changes: 19 additions & 2 deletions pkg/workflow/compiler_activation_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
118 changes: 118 additions & 0 deletions pkg/workflow/permissions_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
}
}
})
}
}
47 changes: 47 additions & 0 deletions pkg/workflow/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"maps"
"os"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions smoke-test-22330477648.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Smoke test file - Run 22330477648
Created by Claude smoke test agent on Tue Feb 24 00:15:09 UTC 2026