Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/go-logger.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/tidy.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/unbloat-docs.lock.yml

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

4 changes: 4 additions & 0 deletions pkg/cli/commands_compile_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,8 @@ on:
branches: [main]
permissions:
contents: read
issues: read
pull-requests: read
---

# Test Workflow
Expand Down Expand Up @@ -617,6 +619,8 @@ on:
branches: [main]
permissions:
contents: read
issues: read
pull-requests: read
---

# Test Workflow
Expand Down
58 changes: 58 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,64 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath
return errors.New(formattedErr)
}

// Validate permissions against GitHub MCP toolsets
log.Printf("Validating permissions for GitHub MCP toolsets")
if githubTool, hasGitHub := workflowData.Tools["github"]; hasGitHub {
// Parse permissions from the workflow data
// WorkflowData.Permissions contains the raw YAML string (including "permissions:" prefix)
permissions := NewPermissionsParser(workflowData.Permissions).ToPermissions()

// Validate permissions
validationResult := ValidatePermissions(permissions, githubTool)

if validationResult.HasValidationIssues {
// Format the validation message
message := FormatValidationMessage(validationResult, c.strictMode)

if len(validationResult.MissingPermissions) > 0 {
// Missing permissions are always errors
formattedErr := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "error",
Message: message,
})
return errors.New(formattedErr)
}

if len(validationResult.ExcessPermissions) > 0 {
// Excess permissions are warnings by default, errors in strict mode
if c.strictMode {
formattedErr := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "error",
Message: message,
})
return errors.New(formattedErr)
} else {
formattedWarning := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "warning",
Message: message,
})
fmt.Fprintln(os.Stderr, formattedWarning)
c.IncrementWarningCount()
}
}
}
}

// Note: Markdown content size is now handled by splitting into multiple steps in generatePrompt

log.Printf("Workflow: %s, Tools: %d", workflowData.Name, len(workflowData.Tools))
Expand Down
55 changes: 55 additions & 0 deletions pkg/workflow/github_toolsets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package workflow

import (
"strings"
)

// DefaultGitHubToolsets defines the toolsets that are enabled by default
// when toolsets are not explicitly specified in the GitHub MCP configuration.
// These match the documented default toolsets in github-mcp-server.instructions.md
var DefaultGitHubToolsets = []string{"context", "repos", "issues", "pull_requests", "users"}

// ParseGitHubToolsets parses the toolsets string and expands "default" and "all"
// into their constituent toolsets. It handles comma-separated lists and deduplicates.
func ParseGitHubToolsets(toolsetsStr string) []string {
if toolsetsStr == "" {
return DefaultGitHubToolsets
}

toolsets := strings.Split(toolsetsStr, ",")
var expanded []string
seenToolsets := make(map[string]bool)

for _, toolset := range toolsets {
toolset = strings.TrimSpace(toolset)
if toolset == "" {
continue
}

if toolset == "default" {
// Add default toolsets
for _, dt := range DefaultGitHubToolsets {
if !seenToolsets[dt] {
expanded = append(expanded, dt)
seenToolsets[dt] = true
}
}
} else if toolset == "all" {
// Add all toolsets from the toolset permissions map
for t := range toolsetPermissionsMap {
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function depends on the global toolsetPermissionsMap variable which creates tight coupling. Consider passing the toolset map as a parameter or making it a method on a struct to improve testability and modularity.

Copilot uses AI. Check for mistakes.
if !seenToolsets[t] {
expanded = append(expanded, t)
seenToolsets[t] = true
}
}
} else {
// Add individual toolset
if !seenToolsets[toolset] {
expanded = append(expanded, toolset)
seenToolsets[toolset] = true
}
}
}

return expanded
}
165 changes: 165 additions & 0 deletions pkg/workflow/github_toolsets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package workflow

import (
"testing"
)

func TestDefaultGitHubToolsets(t *testing.T) {
// Verify the default toolsets match the documented defaults
expected := []string{"context", "repos", "issues", "pull_requests", "users"}

if len(DefaultGitHubToolsets) != len(expected) {
t.Errorf("Expected %d default toolsets, got %d", len(expected), len(DefaultGitHubToolsets))
}

for i, toolset := range expected {
if i >= len(DefaultGitHubToolsets) || DefaultGitHubToolsets[i] != toolset {
t.Errorf("Expected default toolset[%d] to be %s, got %s", i, toolset, DefaultGitHubToolsets[i])
}
}
}

func TestParseGitHubToolsets(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "Empty string returns default",
input: "",
expected: []string{"context", "repos", "issues", "pull_requests", "users"},
},
{
name: "Default expands to default toolsets",
input: "default",
expected: []string{"context", "repos", "issues", "pull_requests", "users"},
},
{
name: "Specific toolsets",
input: "repos,issues",
expected: []string{"repos", "issues"},
},
{
name: "Default plus additional",
input: "default,discussions",
expected: []string{"context", "repos", "issues", "pull_requests", "users", "discussions"},
},
{
name: "All expands to all toolsets",
input: "all",
// Should include all 19 toolsets - we'll check the count
expected: nil,
},
{
name: "Deduplication",
input: "repos,issues,repos",
expected: []string{"repos", "issues"},
},
{
name: "Whitespace handling",
input: " repos , issues , pull_requests ",
expected: []string{"repos", "issues", "pull_requests"},
},
{
name: "Single toolset",
input: "actions",
expected: []string{"actions"},
},
{
name: "Multiple with default in middle",
input: "actions,default,discussions",
expected: []string{"actions", "context", "repos", "issues", "pull_requests", "users", "discussions"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ParseGitHubToolsets(tt.input)

if tt.name == "All expands to all toolsets" {
// Check that all toolsets are present
if len(result) != len(toolsetPermissionsMap) {
t.Errorf("Expected %d toolsets for 'all', got %d", len(toolsetPermissionsMap), len(result))
}
return
}

if len(result) != len(tt.expected) {
t.Errorf("Expected %d toolsets, got %d: %v", len(tt.expected), len(result), result)
return
}

// Check that all expected toolsets are present (order doesn't matter for some tests)
resultMap := make(map[string]bool)
for _, ts := range result {
resultMap[ts] = true
}

for _, expected := range tt.expected {
if !resultMap[expected] {
t.Errorf("Expected toolset %s not found in result: %v", expected, result)
}
}
})
}
}

func TestParseGitHubToolsetsPreservesOrder(t *testing.T) {
// Test that specific toolsets maintain their order
input := "repos,issues,pull_requests"
result := ParseGitHubToolsets(input)
expected := []string{"repos", "issues", "pull_requests"}

if len(result) != len(expected) {
t.Fatalf("Expected %d toolsets, got %d", len(expected), len(result))
}

for i, toolset := range expected {
if result[i] != toolset {
t.Errorf("Expected toolset[%d] to be %s, got %s", i, toolset, result[i])
}
}
}

func TestParseGitHubToolsetsDeduplication(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{
name: "Duplicate in simple list",
input: "repos,issues,repos,issues",
expected: 2,
},
{
name: "Default includes duplicates",
input: "context,default",
expected: 5, // context already in default, so only 5 unique
},
{
name: "All with duplicates",
input: "all,repos,issues",
expected: len(toolsetPermissionsMap), // All toolsets, duplicates ignored
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ParseGitHubToolsets(tt.input)
if len(result) != tt.expected {
t.Errorf("Expected %d unique toolsets, got %d: %v", tt.expected, len(result), result)
}

// Verify no duplicates
seen := make(map[string]bool)
for _, toolset := range result {
if seen[toolset] {
t.Errorf("Found duplicate toolset: %s", toolset)
}
seen[toolset] = true
}
})
}
}
6 changes: 6 additions & 0 deletions pkg/workflow/imports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ tools:
on: issues
permissions:
contents: read
issues: read
pull-requests: read
engine: copilot
imports:
- shared-tool.md
Expand Down Expand Up @@ -110,6 +112,8 @@ tools:
on: issues
permissions:
contents: read
issues: read
pull-requests: read
engine: copilot
imports:
- shared-tool-1.md
Expand Down Expand Up @@ -184,6 +188,8 @@ mcp-servers:
on: issues
permissions:
contents: read
issues: read
pull-requests: read
engine: copilot
imports:
- shared-mcp.md
Expand Down
Loading
Loading