{{ message }}
Repository navigation
Expand file tree
/
Copy pathagent_validation.go
More file actions
531 lines (476 loc) · 21.7 KB
/
Copy pathagent_validation.go
File metadata and controls
531 lines (476 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
// This file provides agent file and feature support validation.
//
// # Agent Validation
//
// This file validates agent-specific configuration and feature compatibility
// for agentic workflows. It ensures that:
// - Custom agent files exist when specified
// - Engine features are supported (HTTP transport, max-turns, web-search, bare mode)
// - Workflow triggers have appropriate security constraints
//
// # Validation Functions
//
// - validateAgentFile() - Validates custom agent file exists
// - validateMaxTurnsSupport() - Validates max-turns feature support
// - validateMaxContinuationsSupport() - Validates max-continuations feature support
// - validateMaxToolDenialsSupport() - Validates max-tool-denials support for Copilot SDK mode
// - validateWebSearchSupport() - Validates web-search feature support (warning)
// - validateBareModeSupport() - Validates bare mode feature support (warning)
// - validateBashCommandAllowlistSupport() - Errors when restricted bash allowlist is unsupported
// - validateWorkflowRunBranches() - Validates workflow_run has branch restrictions
//
// # Validation Patterns
//
// This file uses several patterns:
// - File existence validation: Agent files
// - Feature compatibility checks: Engine capabilities
// - Security validation: workflow_run branch restrictions
// - Warning vs error: Some validations warn instead of fail
//
// # Security Considerations
//
// The validateWorkflowRunBranches function enforces security best practices:
// - In strict mode: Errors when workflow_run lacks branch restrictions
// - In normal mode: Warns when workflow_run lacks branch restrictions
//
// # When to Add Validation Here
//
// Add validation to this file when:
// - It validates custom agent file configuration
// - It checks engine feature compatibility
// - It validates agent-specific requirements
// - It enforces security constraints on triggers
//
// For general validation, see validation.go.
// For detailed documentation, see scratchpad/validation-architecture.md
package workflow
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/goccy/go-yaml"
)
var agentValidationLog = logger.New("workflow:agent_validation")
// validateAgentFile validates that the custom agent file specified in imports exists
func (c *Compiler) validateAgentFile(workflowData *WorkflowData, markdownPath string) error {
// Check if agent file is specified in imports
if workflowData.AgentFile == "" {
return nil // No agent file specified, no validation needed
}
agentPath := workflowData.AgentFile
agentValidationLog.Printf("Validating agent file exists: %s", agentPath)
// Validate path characters to prevent shell injection via crafted filenames.
// Only alphanumeric characters, dots, underscores, hyphens, forward slashes,
// and spaces are permitted. Shell metacharacters are rejected.
if !agentFilePathRegex.MatchString(agentPath) {
return formatCompilerError(markdownPath, "error",
fmt.Sprintf("agent file path '%s' contains invalid characters. Only alphanumeric characters, dots, underscores, hyphens, forward slashes, and spaces are allowed.", agentPath), nil)
}
var fullAgentPath string
// Check if agentPath is already absolute
if filepath.IsAbs(agentPath) {
// Use the path as-is (for backward compatibility with tests)
fullAgentPath = agentPath
} else {
// Agent file path is relative to repository root (e.g., ".github/agents/file.md")
// Need to resolve it relative to the markdown file's directory
markdownDir := filepath.Dir(markdownPath)
// Navigate up from .github/workflows to repository root
repoRoot := filepath.Join(markdownDir, "..", "..")
fullAgentPath = filepath.Join(repoRoot, agentPath)
}
// Check if the file exists
if _, err := os.Stat(fullAgentPath); err != nil {
if os.IsNotExist(err) {
return formatCompilerError(markdownPath, "error",
fmt.Sprintf("agent file '%s' does not exist. Ensure the file exists in the repository and is properly imported.", agentPath), nil)
}
// Other error (permissions, etc.)
return formatCompilerError(markdownPath, "error",
fmt.Sprintf("failed to access agent file '%s': %v", agentPath, err), err)
}
if c.verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr(
"✓ Agent file exists: "+agentPath))
}
return nil
}
// validateCapabilitySupport is a shared helper that checks whether an engine supports a
// required capability. It returns an error with a standard "not supported" message when
// the feature is requested but the engine capability is missing.
// Returns nil when featureSet is false (feature not requested) or when the engine supports it.
func validateCapabilitySupport(featureName string, featureSet bool, capabilitySupported bool, engineID string) error {
if !featureSet {
return nil
}
if !capabilitySupported {
return fmt.Errorf("%s not supported: engine '%s' does not support the %s feature", featureName, engineID, featureName)
}
return nil
}
// validateMaxTurnsSupport validates that max-turns is only used with engines that support this feature
func (c *Compiler) validateMaxTurnsSupport(frontmatter map[string]any, engine CodingAgentEngine) error {
// Check if max-turns is specified in the engine config
_, engineConfig, _ := c.ExtractEngineConfig(frontmatter)
hasMaxTurns := engineConfig != nil && engineConfig.MaxTurns != ""
if hasMaxTurns {
agentValidationLog.Printf("Validating max-turns support: engine=%s", engine.GetID())
}
return validateCapabilitySupport("max-turns", hasMaxTurns, engine.GetCapabilities().MaxTurns, engine.GetID())
}
// emitContextWindowSupportWarning warns when the resolved engine configuration,
// including imported engine settings, sets context-window for an unsupported engine.
func (c *Compiler) emitContextWindowSupportWarning(workflowData *WorkflowData) {
if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.ContextWindow == 0 {
return
}
engine, err := c.getAgenticEngine(workflowData.AI)
if err != nil {
return
}
c.validateContextWindowSupport(workflowData.EngineConfig, engine)
}
// validateContextWindowSupport warns when context-window is used with an engine that cannot consume it.
func (c *Compiler) validateContextWindowSupport(engineConfig *EngineConfig, engine CodingAgentEngine) {
if engineConfig == nil || engineConfig.ContextWindow == 0 || engine.GetCapabilities().ContextWindow {
return
}
agentValidationLog.Printf("Engine %s does not support context-window, emitting warning", engine.GetID())
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf(
"Engine '%s' does not support context-window (engine.context-window). The setting will be ignored.",
engine.GetID())))
c.IncrementWarningCount()
}
// validateMaxContinuationsSupport validates that max-continuations is only used with engines that support this feature
func (c *Compiler) validateMaxContinuationsSupport(frontmatter map[string]any, engine CodingAgentEngine) error {
// Check if max-continuations is specified in the engine config
_, engineConfig, _ := c.ExtractEngineConfig(frontmatter)
hasMaxContinuations := engineConfig != nil && engineConfig.MaxContinuations != 0
if hasMaxContinuations {
agentValidationLog.Printf("Validating max-continuations support: engine=%s", engine.GetID())
}
return validateCapabilitySupport("max-continuations", hasMaxContinuations, engine.GetCapabilities().MaxContinuations, engine.GetID())
}
// validateMaxToolDenialsSupport validates that max-tool-denials is only used with
// the Copilot engine in Copilot SDK mode.
func (c *Compiler) validateMaxToolDenialsSupport(frontmatter map[string]any, engine CodingAgentEngine) error {
_, engineConfig, _ := c.ExtractEngineConfig(frontmatter)
if engineConfig == nil || engineConfig.MaxToolDenials == "" {
return nil
}
agentValidationLog.Printf("Validating max-tool-denials support: engine=%s, maxToolDenials=%s, copilotSDK=%v",
engine.GetID(), engineConfig.MaxToolDenials, engineConfig.CopilotSDK)
if engine.GetID() == string(constants.PiEngine) && (engineConfig.Driver == "" || engineConfig.Driver == "pi_agent_core_driver.cjs" || engineConfig.Driver == "pi_rpc_driver.cjs") {
return nil
}
if engine.GetID() != string(constants.CopilotEngine) {
return fmt.Errorf("max-tool-denials not supported: engine '%s' does not support max-tool-denials (supported only with engine 'copilot' and engine.copilot-sdk: true)", engine.GetID())
}
if !engineConfig.CopilotSDK {
return errors.New("max-tool-denials requires Copilot SDK mode: set engine.copilot-sdk: true when using max-tool-denials")
}
return nil
}
// validateMaxToolCallsSupport limits aggregate tool-call enforcement to the built-in
// Copilot SDK driver, whose pre-tool hook intercepts primary and subagent dispatches.
func (c *Compiler) validateMaxToolCallsSupport(frontmatter map[string]any, engine CodingAgentEngine) error {
_, engineConfig, _ := c.ExtractEngineConfig(frontmatter)
return validateMaxToolCallsConfig(engineConfig, engine)
}
func validateMaxToolCallsConfig(engineConfig *EngineConfig, engine CodingAgentEngine) error {
if engineConfig == nil || engineConfig.MaxToolCalls == "" {
return nil
}
if engine.GetID() == string(constants.PiEngine) {
if engineConfig.Driver != "" && engineConfig.Driver != "pi_agent_core_driver.cjs" && engineConfig.Driver != "pi_rpc_driver.cjs" {
return errors.New("max-tool-calls requires the built-in Pi CLI, SDK, or RPC driver; custom Pi drivers cannot enforce the aggregate budget")
}
return nil
}
if engine.GetID() != string(constants.CopilotEngine) || !engineConfig.CopilotSDK {
return errors.New("max-tool-calls requires engine 'copilot' with engine.copilot-sdk: true; other engines do not currently expose an enforceable tool-dispatch hook")
}
if engineConfig.Driver != "" || engineConfig.HarnessScript != "" {
return errors.New("max-tool-calls requires the built-in Copilot SDK driver and harness; custom engine.driver or engine.harness overrides cannot enforce the aggregate budget")
}
return nil
}
// validateUniversalLLMConsumerModel validates that universal consumer engines
// (behavior-defined engines using the universal-llm-consumer secret strategy)
// declare a provider-qualified engine.model.
func (c *Compiler) validateUniversalLLMConsumerModel(frontmatter map[string]any, engine CodingAgentEngine) error {
behaviorEngine, ok := engine.(*BehaviorDefinedEngine)
if !ok || !behaviorEngine.usesUniversalLLMConsumer() {
return nil
}
_, engineConfig, model := c.ExtractEngineConfig(frontmatter)
if engineConfig == nil || strings.TrimSpace(model) == "" {
return fmt.Errorf("engine.model is required for engine '%s' and must use provider/model format (for example: copilot/gpt-5, anthropic/claude-sonnet-4, openai/gpt-4.1)", engine.GetID())
}
if _, err := resolveUniversalLLMBackendFromModel(model); err != nil {
return fmt.Errorf("invalid engine.model for engine '%s': %w", engine.GetID(), err)
}
return nil
}
// validateCopilotWebToolsSupport uses the resolved engine configuration so imported
// SDK settings are honored. Native web tools are unavailable in offline BYOK mode.
func (c *Compiler) validateCopilotWebToolsSupport(engine CodingAgentEngine, engineConfig *EngineConfig, tools map[string]any) error {
if engine.GetID() != string(constants.CopilotEngine) {
return nil
}
collector := NewErrorCollector(c.failFast)
for _, tool := range []string{"web-fetch", "web-search"} {
if !isCopilotToolValueEnabled(tools, tool) || (tool == "web-fetch" && engineConfig != nil && engineConfig.CopilotSDK) {
continue
}
suggestion := fmt.Sprintf("Remove tools.%s and configure an MCP server instead, or use a supported engine, for example:\n\nengine: codex", tool)
if tool == "web-fetch" {
suggestion += "\n\nFor Copilot's proxy-aware custom fetch tool, enable SDK mode:\n\nengine:\n id: copilot\n copilot-sdk: true"
}
err := NewValidationError(
"tools."+tool,
tool,
fmt.Sprintf("Copilot's native '%s' tool is unavailable because offline BYOK mode disables web tools (COPILOT_OFFLINE=true)", tool),
suggestion,
)
if returnErr := collector.Add(err); returnErr != nil {
return returnErr
}
}
return collector.FormattedError("Copilot web tools")
}
// validateWebSearchSupport validates that web-search tool is only used with engines that support this feature
func (c *Compiler) validateWebSearchSupport(frontmatter map[string]any, tools map[string]any, engine CodingAgentEngine) {
// Check if web-search tool is requested
_, hasWebSearch := tools["web-search"]
if !hasWebSearch {
// No web-search specified, no validation needed
return
}
if engine.GetID() == string(constants.CopilotEngine) {
// Copilot's unavailable native tools are rejected after engine imports resolve.
return
}
agentValidationLog.Printf("Validating web-search support for engine: %s", engine.GetID())
// web-search is specified, check if the engine supports it
if !engine.GetCapabilities().WebSearch {
agentValidationLog.Printf("Engine %s does not natively support web-search tool, emitting warning", engine.GetID())
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Engine '%s' does not support the web-search tool. See https://github.github.com/gh-aw/guides/web-search/ for alternatives.", engine.GetID())))
c.IncrementWarningCount()
return
}
}
// validateBareModeSupport validates that bare mode is only used with engines that support this feature.
// Emits a warning and has no effect on engines that do not support bare mode.
func (c *Compiler) validateBareModeSupport(frontmatter map[string]any, engine CodingAgentEngine) {
_, engineConfig, _ := c.ExtractEngineConfig(frontmatter)
if engineConfig == nil || !engineConfig.Bare {
// bare mode not requested, no validation needed
return
}
agentValidationLog.Printf("Validating bare mode support for engine: %s", engine.GetID())
if !engine.GetCapabilities().BareMode {
agentValidationLog.Printf("Engine %s does not support bare mode, emitting warning", engine.GetID())
fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(fmt.Sprintf("Engine '%s' does not support bare mode (engine.bare: true). Bare mode is only supported for the 'copilot' and 'claude' engines. The setting will be ignored.", engine.GetID())))
c.IncrementWarningCount()
}
}
// validateBashCommandAllowlistSupport errors when an explicit bash restriction is used
// with an engine that cannot enforce it. An explicit restriction is any of:
// - bash: false (disabling bash — silently ignored at runtime)
// - bash: [] (empty allowlist — silently ignored at runtime)
// - bash: [cmd1, cmd2, ...] (non-wildcard list — silently ignored at runtime)
//
// Engines that do not map these configurations to their own CLI syntax silently ignore them,
// creating the dangerous illusion of restriction where none exists.
func (c *Compiler) validateBashCommandAllowlistSupport(tools map[string]any, engine CodingAgentEngine) error {
capabilities := engine.GetCapabilities()
if capabilities.BashCommandAllowlist {
return nil
}
if !HasBashExplicitRestriction(tools) {
return nil
}
if capabilities.BashDisable && hasBashFullyDisabled(tools) {
// The engine cannot enforce a per-command allowlist, but tools.bash here requests a
// complete refusal of all bash commands (bash: false, or bash: []), which the engine
// can honor directly (e.g. Codex's features.shell_tool=false), so this is safe to allow.
agentValidationLog.Printf("Engine %s fully disables bash instead of enforcing an allowlist", engine.GetID())
return nil
}
agentValidationLog.Printf("Engine %s does not support bash command allowlist, emitting error", engine.GetID())
return fmt.Errorf("engine '%s' does not support bash command allow-listing: tools.bash with specific commands is silently ignored at runtime for this engine. "+
"Use 'bash: [\"*\"]' to allow all commands, 'bash: false' to fully disable bash, or remove the tools.bash entry. "+
"To restrict bash commands to a specific allowlist, switch to an engine that supports this feature (copilot, claude, or gemini)",
engine.GetID())
}
// HasBashExplicitRestriction reports true when the tools map contains a bash configuration
// that represents an explicit restriction: bash: false, bash: [], or a non-wildcard command list.
// Only absent/nil bash, bash: true, and wildcard lists (["*"], [":*"]) return false.
// This function is used for compile-time validation and by the `gh aw fix` codemods.
// See hasBashRestrictedAllowlist for the variant used in MCP CLI command injection.
func HasBashExplicitRestriction(tools map[string]any) bool {
if tools == nil {
return false
}
bashConfig, hasBash := tools["bash"]
if !hasBash || bashConfig == nil {
return false
}
if asBool, ok := bashConfig.(bool); ok {
// bash: false disables bash (explicit restriction); bash: true allows all (unrestricted)
return !asBool
}
bashCommands, ok := bashConfig.([]any)
if !ok {
return false
}
// empty list explicitly allows no commands — that is a restriction
if len(bashCommands) == 0 {
return true
}
for _, cmd := range bashCommands {
if cmdStr, ok := cmd.(string); ok && (cmdStr == "*" || cmdStr == ":*") {
return false
}
}
return true
}
// hasBashFullyDisabled reports true when the tools map's bash configuration represents a
// complete refusal of all bash commands: bash: false, or bash: [] (empty allowlist). Unlike
// hasBashExplicitRestriction, this excludes non-empty, non-wildcard command lists, which require
// per-command allowlist support (EngineCapabilities.BashCommandAllowlist) to enforce safely.
// It is used to decide whether EngineCapabilities.BashDisable is sufficient to allow an explicit
// bash restriction that would otherwise be rejected by validateBashCommandAllowlistSupport.
func hasBashFullyDisabled(tools map[string]any) bool {
if tools == nil {
return false
}
bashConfig, hasBash := tools["bash"]
if !hasBash || bashConfig == nil {
return false
}
if asBool, ok := bashConfig.(bool); ok {
return !asBool
}
bashCommands, ok := bashConfig.([]any)
if !ok {
return false
}
return len(bashCommands) == 0
}
// validateWorkflowRunBranches validates workflow_run trigger requirements.
// It enforces required workflows and branch restrictions guidance.
func (c *Compiler) validateWorkflowRunBranches(workflowData *WorkflowData, markdownPath string) error {
if !strings.Contains(workflowData.On, "workflow_run") {
return nil
}
agentValidationLog.Print("Validating workflow_run trigger requirements")
workflowRunMap, ok := parseWorkflowRunTrigger(workflowData.On)
if !ok {
return nil
}
if err := validateWorkflowRunHasWorkflows(workflowRunMap, markdownPath); err != nil {
return err
}
if _, hasBranches := workflowRunMap["branches"]; hasBranches {
if c.verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessageStderr("✓ workflow_run trigger has branch restrictions"))
}
return nil
}
return c.emitWorkflowRunMissingBranches(markdownPath)
}
func parseWorkflowRunTrigger(onYAML string) (map[string]any, bool) {
var parsedData map[string]any
if err := yaml.Unmarshal([]byte(onYAML), &parsedData); err != nil {
agentValidationLog.Printf("Could not parse On field as YAML: %v", err)
return nil, false
}
onData, hasOn := parsedData["on"]
if !hasOn {
return nil, false
}
onMap, isMap := onData.(map[string]any)
if !isMap {
return nil, false
}
workflowRunVal, hasWorkflowRun := onMap["workflow_run"]
if !hasWorkflowRun {
return nil, false
}
workflowRunMap, ok := workflowRunVal.(map[string]any)
return workflowRunMap, ok
}
func validateWorkflowRunHasWorkflows(workflowRunMap map[string]any, markdownPath string) error {
workflowsVal, hasWorkflows := workflowRunMap["workflows"]
if hasWorkflows && hasNonEmptyWorkflowRunWorkflows(workflowsVal) {
return nil
}
message := `workflow_run trigger must include a non-empty workflows field.
GitHub Actions requires on.workflow_run.workflows to reference at least one workflow.
Without it, the compiled workflow is invalid and will be rejected.
Suggested fix:
on:
workflow_run:
workflows: ["your-workflow"]
types: [completed]`
return formatCompilerError(markdownPath, "error", message, nil)
}
func (c *Compiler) emitWorkflowRunMissingBranches(markdownPath string) error {
message := "workflow_run trigger should include branch restrictions for security and performance.\n\n" +
"Without branch restrictions, the workflow will run for workflow runs on ALL branches,\n" +
"which can cause unexpected behavior and security issues.\n\n" +
"Suggested fix: Add branch restrictions to your workflow_run trigger:\n" +
"on:\n" +
" workflow_run:\n" +
" workflows: [\"your-workflow\"]\n" +
" types: [completed]\n" +
" branches:\n" +
" - main\n" +
" - develop"
if c.strictMode {
return formatCompilerError(markdownPath, "error", message, nil)
}
formattedWarning := formatCompilerMessage(markdownPath, "warning", message)
fmt.Fprintln(os.Stderr, formattedWarning)
c.IncrementWarningCount()
return nil
}
// hasNonEmptyWorkflowRunWorkflows returns true when workflow_run.workflows
// includes at least one non-empty workflow name.
//
// Supported types:
// - string: valid when non-empty after trimming whitespace
// - []string: valid when any item is non-empty after trimming whitespace
// - []any: valid when any string item is non-empty after trimming whitespace
//
// For all other types, it returns false.
func hasNonEmptyWorkflowRunWorkflows(v any) bool {
switch workflows := v.(type) {
case string:
return strings.TrimSpace(workflows) != ""
case []string:
for _, workflow := range workflows {
if strings.TrimSpace(workflow) != "" {
return true
}
}
return false
case []any:
for _, workflow := range workflows {
s, ok := workflow.(string)
if ok && strings.TrimSpace(s) != "" {
return true
}
}
return false
default:
return false
}
}
You can’t perform that action at this time.
