|
| 1 | +// Package workflow provides agent file and feature support validation. |
| 2 | +// |
| 3 | +// # Agent Validation |
| 4 | +// |
| 5 | +// This file validates agent-specific configuration and feature compatibility |
| 6 | +// for agentic workflows. It ensures that: |
| 7 | +// - Custom agent files exist when specified |
| 8 | +// - Engine features are supported (HTTP transport, max-turns, web-search) |
| 9 | +// - Workflow triggers have appropriate security constraints |
| 10 | +// |
| 11 | +// # Validation Functions |
| 12 | +// |
| 13 | +// - validateAgentFile() - Validates custom agent file exists |
| 14 | +// - validateHTTPTransportSupport() - Validates HTTP MCP compatibility with engine |
| 15 | +// - validateMaxTurnsSupport() - Validates max-turns feature support |
| 16 | +// - validateWebSearchSupport() - Validates web-search feature support (warning) |
| 17 | +// - validateWorkflowRunBranches() - Validates workflow_run has branch restrictions |
| 18 | +// |
| 19 | +// # Validation Patterns |
| 20 | +// |
| 21 | +// This file uses several patterns: |
| 22 | +// - File existence validation: Agent files |
| 23 | +// - Feature compatibility checks: Engine capabilities |
| 24 | +// - Security validation: workflow_run branch restrictions |
| 25 | +// - Warning vs error: Some validations warn instead of fail |
| 26 | +// |
| 27 | +// # Security Considerations |
| 28 | +// |
| 29 | +// The validateWorkflowRunBranches function enforces security best practices: |
| 30 | +// - In strict mode: Errors when workflow_run lacks branch restrictions |
| 31 | +// - In normal mode: Warns when workflow_run lacks branch restrictions |
| 32 | +// |
| 33 | +// # When to Add Validation Here |
| 34 | +// |
| 35 | +// Add validation to this file when: |
| 36 | +// - It validates custom agent file configuration |
| 37 | +// - It checks engine feature compatibility |
| 38 | +// - It validates agent-specific requirements |
| 39 | +// - It enforces security constraints on triggers |
| 40 | +// |
| 41 | +// For general validation, see validation.go. |
| 42 | +// For detailed documentation, see specs/validation-architecture.md |
| 43 | +package workflow |
| 44 | + |
| 45 | +import ( |
| 46 | + "errors" |
| 47 | + "fmt" |
| 48 | + "os" |
| 49 | + "path/filepath" |
| 50 | + |
| 51 | + "github.com/githubnext/gh-aw/pkg/console" |
| 52 | + "github.com/githubnext/gh-aw/pkg/logger" |
| 53 | + "github.com/goccy/go-yaml" |
| 54 | +) |
| 55 | + |
| 56 | +var agentValidationLog = logger.New("workflow:agent_validation") |
| 57 | + |
| 58 | +// validateAgentFile validates that the custom agent file specified in imports exists |
| 59 | +func (c *Compiler) validateAgentFile(workflowData *WorkflowData, markdownPath string) error { |
| 60 | + // Check if agent file is specified in imports |
| 61 | + if workflowData.AgentFile == "" { |
| 62 | + return nil // No agent file specified, no validation needed |
| 63 | + } |
| 64 | + |
| 65 | + agentPath := workflowData.AgentFile |
| 66 | + agentValidationLog.Printf("Validating agent file exists: %s", agentPath) |
| 67 | + |
| 68 | + var fullAgentPath string |
| 69 | + |
| 70 | + // Check if agentPath is already absolute |
| 71 | + if filepath.IsAbs(agentPath) { |
| 72 | + // Use the path as-is (for backward compatibility with tests) |
| 73 | + fullAgentPath = agentPath |
| 74 | + } else { |
| 75 | + // Agent file path is relative to repository root (e.g., ".github/agents/file.md") |
| 76 | + // Need to resolve it relative to the markdown file's directory |
| 77 | + markdownDir := filepath.Dir(markdownPath) |
| 78 | + // Navigate up from .github/workflows to repository root |
| 79 | + repoRoot := filepath.Join(markdownDir, "..", "..") |
| 80 | + fullAgentPath = filepath.Join(repoRoot, agentPath) |
| 81 | + } |
| 82 | + |
| 83 | + // Check if the file exists |
| 84 | + if _, err := os.Stat(fullAgentPath); err != nil { |
| 85 | + if os.IsNotExist(err) { |
| 86 | + formattedErr := console.FormatError(console.CompilerError{ |
| 87 | + Position: console.ErrorPosition{ |
| 88 | + File: markdownPath, |
| 89 | + Line: 1, |
| 90 | + Column: 1, |
| 91 | + }, |
| 92 | + Type: "error", |
| 93 | + Message: fmt.Sprintf("agent file '%s' does not exist. Ensure the file exists in the repository and is properly imported.", agentPath), |
| 94 | + }) |
| 95 | + return errors.New(formattedErr) |
| 96 | + } |
| 97 | + // Other error (permissions, etc.) |
| 98 | + formattedErr := console.FormatError(console.CompilerError{ |
| 99 | + Position: console.ErrorPosition{ |
| 100 | + File: markdownPath, |
| 101 | + Line: 1, |
| 102 | + Column: 1, |
| 103 | + }, |
| 104 | + Type: "error", |
| 105 | + Message: fmt.Sprintf("failed to access agent file '%s': %v", agentPath, err), |
| 106 | + }) |
| 107 | + return errors.New(formattedErr) |
| 108 | + } |
| 109 | + |
| 110 | + if c.verbose { |
| 111 | + fmt.Fprintln(os.Stderr, console.FormatInfoMessage( |
| 112 | + fmt.Sprintf("✓ Agent file exists: %s", agentPath))) |
| 113 | + } |
| 114 | + |
| 115 | + return nil |
| 116 | +} |
| 117 | + |
| 118 | +// validateHTTPTransportSupport validates that HTTP MCP servers are only used with engines that support HTTP transport |
| 119 | +func (c *Compiler) validateHTTPTransportSupport(tools map[string]any, engine CodingAgentEngine) error { |
| 120 | + if engine.SupportsHTTPTransport() { |
| 121 | + // Engine supports HTTP transport, no validation needed |
| 122 | + return nil |
| 123 | + } |
| 124 | + |
| 125 | + // Engine doesn't support HTTP transport, check for HTTP MCP servers |
| 126 | + for toolName, toolConfig := range tools { |
| 127 | + if config, ok := toolConfig.(map[string]any); ok { |
| 128 | + if hasMcp, mcpType := hasMCPConfig(config); hasMcp && mcpType == "http" { |
| 129 | + return fmt.Errorf("tool '%s' uses HTTP transport which is not supported by engine '%s' (only stdio transport is supported)", toolName, engine.GetID()) |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + return nil |
| 135 | +} |
| 136 | + |
| 137 | +// validateMaxTurnsSupport validates that max-turns is only used with engines that support this feature |
| 138 | +func (c *Compiler) validateMaxTurnsSupport(frontmatter map[string]any, engine CodingAgentEngine) error { |
| 139 | + // Check if max-turns is specified in the engine config |
| 140 | + engineSetting, engineConfig := c.ExtractEngineConfig(frontmatter) |
| 141 | + _ = engineSetting // Suppress unused variable warning |
| 142 | + |
| 143 | + hasMaxTurns := engineConfig != nil && engineConfig.MaxTurns != "" |
| 144 | + |
| 145 | + if !hasMaxTurns { |
| 146 | + // No max-turns specified, no validation needed |
| 147 | + return nil |
| 148 | + } |
| 149 | + |
| 150 | + // max-turns is specified, check if the engine supports it |
| 151 | + if !engine.SupportsMaxTurns() { |
| 152 | + return fmt.Errorf("max-turns not supported: engine '%s' does not support the max-turns feature", engine.GetID()) |
| 153 | + } |
| 154 | + |
| 155 | + // Engine supports max-turns - additional validation could be added here if needed |
| 156 | + // For now, we rely on JSON schema validation for format checking |
| 157 | + |
| 158 | + return nil |
| 159 | +} |
| 160 | + |
| 161 | +// validateWebSearchSupport validates that web-search tool is only used with engines that support this feature |
| 162 | +func (c *Compiler) validateWebSearchSupport(tools map[string]any, engine CodingAgentEngine) { |
| 163 | + // Check if web-search tool is requested |
| 164 | + _, hasWebSearch := tools["web-search"] |
| 165 | + |
| 166 | + if !hasWebSearch { |
| 167 | + // No web-search specified, no validation needed |
| 168 | + return |
| 169 | + } |
| 170 | + |
| 171 | + // web-search is specified, check if the engine supports it |
| 172 | + if !engine.SupportsWebSearch() { |
| 173 | + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Engine '%s' does not support the web-search tool. See https://githubnext.github.io/gh-aw/guides/web-search/ for alternatives.", engine.GetID()))) |
| 174 | + c.IncrementWarningCount() |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +// validateWorkflowRunBranches validates that workflow_run triggers include branch restrictions |
| 179 | +// This is a security best practice to avoid running on all branches |
| 180 | +func (c *Compiler) validateWorkflowRunBranches(workflowData *WorkflowData, markdownPath string) error { |
| 181 | + if workflowData.On == "" { |
| 182 | + return nil |
| 183 | + } |
| 184 | + |
| 185 | + agentValidationLog.Print("Validating workflow_run triggers for branch restrictions") |
| 186 | + |
| 187 | + // Parse the On field as YAML to check for workflow_run |
| 188 | + // The On field is a YAML string that starts with "on:" key |
| 189 | + var parsedData map[string]any |
| 190 | + if err := yaml.Unmarshal([]byte(workflowData.On), &parsedData); err != nil { |
| 191 | + // If we can't parse the YAML, skip this validation |
| 192 | + agentValidationLog.Printf("Could not parse On field as YAML: %v", err) |
| 193 | + return nil |
| 194 | + } |
| 195 | + |
| 196 | + // Extract the actual "on" section from the parsed data |
| 197 | + onData, hasOn := parsedData["on"] |
| 198 | + if !hasOn { |
| 199 | + // No "on" key found, skip validation |
| 200 | + return nil |
| 201 | + } |
| 202 | + |
| 203 | + onMap, isMap := onData.(map[string]any) |
| 204 | + if !isMap { |
| 205 | + // "on" is not a map, skip validation |
| 206 | + return nil |
| 207 | + } |
| 208 | + |
| 209 | + // Check if workflow_run is present |
| 210 | + workflowRunVal, hasWorkflowRun := onMap["workflow_run"] |
| 211 | + if !hasWorkflowRun { |
| 212 | + // No workflow_run trigger, no validation needed |
| 213 | + return nil |
| 214 | + } |
| 215 | + |
| 216 | + // Check if workflow_run has branches field |
| 217 | + workflowRunMap, isMap := workflowRunVal.(map[string]any) |
| 218 | + if !isMap { |
| 219 | + // workflow_run is not a map (unusual), skip validation |
| 220 | + return nil |
| 221 | + } |
| 222 | + |
| 223 | + _, hasBranches := workflowRunMap["branches"] |
| 224 | + if hasBranches { |
| 225 | + // Has branch restrictions, validation passed |
| 226 | + if c.verbose { |
| 227 | + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("✓ workflow_run trigger has branch restrictions")) |
| 228 | + } |
| 229 | + return nil |
| 230 | + } |
| 231 | + |
| 232 | + // workflow_run without branches - this is a warning or error depending on mode |
| 233 | + message := "workflow_run trigger should include branch restrictions for security and performance.\n\n" + |
| 234 | + "Without branch restrictions, the workflow will run for workflow runs on ALL branches,\n" + |
| 235 | + "which can cause unexpected behavior and security issues.\n\n" + |
| 236 | + "Suggested fix: Add branch restrictions to your workflow_run trigger:\n" + |
| 237 | + "on:\n" + |
| 238 | + " workflow_run:\n" + |
| 239 | + " workflows: [\"your-workflow\"]\n" + |
| 240 | + " types: [completed]\n" + |
| 241 | + " branches:\n" + |
| 242 | + " - main\n" + |
| 243 | + " - develop" |
| 244 | + |
| 245 | + if c.strictMode { |
| 246 | + // In strict mode, this is an error |
| 247 | + formattedErr := console.FormatError(console.CompilerError{ |
| 248 | + Position: console.ErrorPosition{ |
| 249 | + File: markdownPath, |
| 250 | + Line: 1, |
| 251 | + Column: 1, |
| 252 | + }, |
| 253 | + Type: "error", |
| 254 | + Message: message, |
| 255 | + }) |
| 256 | + return errors.New(formattedErr) |
| 257 | + } |
| 258 | + |
| 259 | + // In normal mode, this is a warning |
| 260 | + formattedWarning := console.FormatError(console.CompilerError{ |
| 261 | + Position: console.ErrorPosition{ |
| 262 | + File: markdownPath, |
| 263 | + Line: 1, |
| 264 | + Column: 1, |
| 265 | + }, |
| 266 | + Type: "warning", |
| 267 | + Message: message, |
| 268 | + }) |
| 269 | + fmt.Fprintln(os.Stderr, formattedWarning) |
| 270 | + c.IncrementWarningCount() |
| 271 | + |
| 272 | + return nil |
| 273 | +} |
0 commit comments