diff --git a/.changeset/bash-allowlist-unsupported-engine-guided-error.md b/.changeset/bash-allowlist-unsupported-engine-guided-error.md new file mode 100644 index 00000000000..5357db9894d --- /dev/null +++ b/.changeset/bash-allowlist-unsupported-engine-guided-error.md @@ -0,0 +1,7 @@ +--- +"gh-aw": patch +--- + +`gh aw fix` now reports a guided error when `tools.bash` declares a restriction (a specific command list, an empty list, or `bash: false`) while the workflow uses an engine that ignores bash command allow-listing, such as `codex`. + +Previously this incompatibility was only surfaced by `gh aw compile --strict`, and `gh aw fix` reported "No fixes needed". The new `bash-allowlist-unsupported-engine-guided-error` codemod does not rewrite the workflow automatically because both remediations change semantics: widening the list to `bash: ["*"]` makes the unrestricted access explicit, and switching to `copilot`, `claude`, or `gemini` changes which agent runs the workflow. diff --git a/docs/adr/51102-guided-codemod-for-bash-allowlist-on-unsupported-engines.md b/docs/adr/51102-guided-codemod-for-bash-allowlist-on-unsupported-engines.md new file mode 100644 index 00000000000..94765d3861b --- /dev/null +++ b/docs/adr/51102-guided-codemod-for-bash-allowlist-on-unsupported-engines.md @@ -0,0 +1,44 @@ +# ADR-51102: Guided (Non-Auto-Rewriting) Codemod for Restricted `tools.bash` on Engines That Ignore Allow-Listing + +**Date**: 2026-08-07 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +`gh aw compile --strict` rejects workflows that pair an engine without bash allow-list support (e.g. `codex`) with a restricted `tools.bash` configuration (`bash: [cmd, ...]`, `bash: []`, or `bash: false`). However, `gh aw fix` reported "No fixes needed" for this exact configuration, leaving users with a broken workflow and no remediation path. The incompatibility arises because the engine silently ignores the allow-list at runtime, making the declared restriction meaningless. Two remediations exist — widen to `bash: ["*"]` or switch to a supporting engine — but both change workflow semantics and require human judgment. + +### Decision + +We will add a new guided codemod (`bash-allowlist-unsupported-engine-guided-error`) that detects the restriction pattern, checks the engine's `BashCommandAllowlist` capability from the global engine registry, and emits a descriptive guided error naming both fix options. The codemod never modifies the workflow file; it always returns `applied=false`. The check is capability-driven rather than engine-name-hardcoded so it stays correct as engines are added or changed. The existing `hasBashExplicitRestriction` function in `pkg/workflow/agent_validation.go` is exported as `HasBashExplicitRestriction` to be shared between the compiler and the new codemod, preventing logic drift. + +### Alternatives Considered + +#### Alternative 1: Auto-rewrite `tools.bash` to `bash: ["*"]` + +Auto-correction would silently widen the effective bash permissions granted to the agent, changing what the workflow author explicitly declared. A workflow author who wrote `bash: ["git", "npm"]` intended to restrict bash access; overwriting this to unrestricted access without consent could introduce a security regression. This option was rejected because it changes semantics without user consent. + +#### Alternative 2: Auto-switch the `engine` field to a supported engine + +Automatically changing the engine (e.g. from `codex` to `copilot`) would change which AI agent executes the workflow, altering its behavior in ways unrelated to the bash restriction. The author may have chosen `codex` for reasons beyond bash tooling. This option was rejected because it modifies a high-impact field outside the scope of the bash restriction problem. + +### Consequences + +#### Positive +- Users get a clear, actionable guided error from `gh aw fix` that names both remediation paths, ending the "No fixes needed" false negative. +- The capability-driven check (via `engine.GetCapabilities().BashCommandAllowlist`) keeps the detection accurate without per-engine hardcoding, so new engines that lack allow-list support are caught automatically. +- Sharing `HasBashExplicitRestriction` between the compiler and the codemod eliminates the risk of the two checks diverging over time. + +#### Negative +- Users still must perform the fix manually; no automatic remediation is provided, which may frustrate users expecting `gh aw fix` to resolve issues without human intervention. +- The guided error model requires the codemod framework to support `Guided: true` codemods that return errors without mutations — a subtler contract than standard codemods. + +#### Neutral +- The new codemod is registered after the other bash codemods (`bash-anonymous-removal`, `bash-single-quoted-args-rewrite`) to maintain grouping by concern. +- Unknown or custom engines are treated as a no-op to avoid false positives in environments with private engine registries. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/codemod_bash_allowlist_unsupported_engine.go b/pkg/cli/codemod_bash_allowlist_unsupported_engine.go new file mode 100644 index 00000000000..05a2d4ab215 --- /dev/null +++ b/pkg/cli/codemod_bash_allowlist_unsupported_engine.go @@ -0,0 +1,182 @@ +package cli + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/workflow" +) + +var bashAllowlistUnsupportedEngineCodemodLog = logger.New("cli:codemod_bash_allowlist_unsupported_engine") + +// getBashAllowlistUnsupportedEngineCodemod creates a codemod that emits a guided error when +// a workflow restricts bash commands (tools.bash with a specific command list, an empty list, +// or bash: false) while using an engine that cannot enforce the restriction (for example codex). +// +// The restriction is silently ignored at runtime by such engines, so the compiler rejects it in +// strict mode. It is not auto-corrected because both remediations change semantics: rewriting the +// allow-list to bash: ["*"] widens the effective (declared) permissions, and switching engines +// changes which agent runs the workflow. The user must choose. +func getBashAllowlistUnsupportedEngineCodemod() Codemod { + return Codemod{ + ID: "bash-allowlist-unsupported-engine-guided-error", + Name: "Detect bash allow-list on an engine that ignores it (manual fix required)", + Description: "Detects a restricted 'tools.bash' configuration combined with an engine that does not enforce bash command allow-listing (such as codex), and emits a guided error because the fix (widening to bash: [\"*\"] or switching engines) changes workflow semantics.", + IntroducedIn: "0.78.0", + Guided: true, + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + return applyBashAllowlistUnsupportedEngineCheck(content, frontmatter, "") + }, + ApplyWithContext: func(content string, frontmatter map[string]any, filePath string) (string, bool, error) { + return applyBashAllowlistUnsupportedEngineCheck(content, frontmatter, filePath) + }, + } +} + +// applyBashAllowlistUnsupportedEngineCheck is the shared implementation used by both Apply and +// ApplyWithContext. When filePath is non-empty, effective tools are resolved from imports and +// markdown includes so that restrictions introduced via shared imports are also caught. +func applyBashAllowlistUnsupportedEngineCheck(content string, frontmatter map[string]any, filePath string) (string, bool, error) { + effectiveTools, err := resolveEffectiveBashTools(content, frontmatter, filePath) + if err != nil { + bashAllowlistUnsupportedEngineCodemodLog.Printf("Failed to resolve effective tools: %v", err) + // Fall back to top-level tools only so we never swallow a real error silently. + effectiveTools, _ = frontmatter["tools"].(map[string]any) + } + + // Extract the bash value once so we avoid a double-read: HasBashExplicitRestriction + // inspects it and describeBashRestriction renders it; both see the same value. + bashVal := effectiveTools["bash"] + if !workflow.HasBashExplicitRestriction(effectiveTools) { + return content, false, nil + } + + engineID := extractEngineIDFromFrontmatter(frontmatter) + engine, err := workflow.GetGlobalEngineRegistry().GetEngine(engineID) + if err != nil { + bashAllowlistUnsupportedEngineCodemodLog.Printf("Unknown engine %q, skipping bash allow-list check", engineID) + return content, false, nil + } + if engine.GetCapabilities().BashCommandAllowlist { + return content, false, nil + } + + bashAllowlistUnsupportedEngineCodemodLog.Printf("Engine %s ignores the restricted tools.bash configuration, emitting guided error", engineID) + + // Build the list of supported engines dynamically from the registry so the message stays + // accurate as new engines gain BashCommandAllowlist support. + supportedEngines := workflow.GetGlobalEngineRegistry().EnginesWithCapability(func(c workflow.EngineCapabilities) bool { + return c.BashCommandAllowlist + }) + + return content, false, fmt.Errorf( + "engine '%s' does not support bash command allow-listing: %s is silently ignored at runtime for this engine. "+ + "Manual fix required: switch to an engine that enforces the allow-list (%s), "+ + "or replace the configuration with 'bash: [\"*\"]' to make the unrestricted access explicit. "+ + "See: https://github.github.com/gh-aw/reference/tools/", + engineID, + describeBashRestriction(bashVal), + strings.Join(supportedEngines, ", "), + ) +} + +// resolveEffectiveBashTools returns the effective tools map for the workflow, merging in tools +// from imports and markdown includes when a file path is available. When filePath is empty (for +// example in unit tests), only the raw top-level tools from frontmatter are returned. +// +// The returned map is a best-effort result: if import resolution fails the error is returned so +// the caller can fall back gracefully. +func resolveEffectiveBashTools(content string, frontmatter map[string]any, filePath string) (map[string]any, error) { + topTools, _ := frontmatter["tools"].(map[string]any) + + // Fast path: if the top-level tools already declares a bash key, the top-level value is + // authoritative (it wins in the MergeTools merge), so we never need to resolve imports. + if _, hasBash := topTools["bash"]; hasBash { + return topTools, nil + } + + // If no file path is available (e.g. unit tests), return top-level tools as-is. + if filePath == "" { + return topTools, nil + } + + baseDir := filepath.Dir(filePath) + importCache := parser.NewImportCache("") + + // Resolve tools from frontmatter imports (imports: [...] section). + importsResult, err := parser.ProcessImportsFromFrontmatterWithSource(frontmatter, baseDir, importCache, filePath, content) + if err != nil { + return nil, fmt.Errorf("resolving imports: %w", err) + } + + // Resolve tools from markdown directives. + includedTools, _, err := parser.ExpandIncludesWithManifest(content, baseDir, true) + if err != nil { + return nil, fmt.Errorf("expanding includes: %w", err) + } + + // Combine all imported and included tools lines (same format as the compiler). + allExternalTools := strings.Join(nonEmptyStrs(importsResult.MergedTools, includedTools), "\n") + if allExternalTools == "" { + return topTools, nil + } + + // Merge external tools into the top-level tools map, line by line (each line is a JSON object). + effective := make(map[string]any) + if topTools != nil { + for k, v := range topTools { + effective[k] = v + } + } + for line := range strings.SplitSeq(allExternalTools, "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "{}" { + continue + } + var imported map[string]any + if err := json.Unmarshal([]byte(line), &imported); err != nil { + continue + } + merged, err := parser.MergeTools(effective, imported) + if err != nil { + return nil, fmt.Errorf("merging tools from import: %w", err) + } + effective = merged + } + + return effective, nil +} + +// nonEmptyStrs returns a slice containing only the non-empty strings from the arguments. +func nonEmptyStrs(strs ...string) []string { + out := make([]string, 0, len(strs)) + for _, s := range strs { + if strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out +} + +// describeBashRestriction renders a short human-readable description of the offending +// tools.bash configuration for use in the guided error message. +func describeBashRestriction(bashConfig any) string { + switch value := bashConfig.(type) { + case bool: + return fmt.Sprintf("'bash: %t'", value) + case []any: + if len(value) == 0 { + return "'bash: []'" + } + commands := make([]string, 0, len(value)) + for _, cmd := range value { + commands = append(commands, fmt.Sprintf("%q", fmt.Sprintf("%v", cmd))) + } + return fmt.Sprintf("'bash: [%s]'", strings.Join(commands, ", ")) + } + return "the tools.bash configuration" +} diff --git a/pkg/cli/codemod_bash_allowlist_unsupported_engine_test.go b/pkg/cli/codemod_bash_allowlist_unsupported_engine_test.go new file mode 100644 index 00000000000..9eed4e52e1e --- /dev/null +++ b/pkg/cli/codemod_bash_allowlist_unsupported_engine_test.go @@ -0,0 +1,230 @@ +//go:build !integration + +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBashAllowlistUnsupportedEngineCodemod_Metadata(t *testing.T) { + codemod := getBashAllowlistUnsupportedEngineCodemod() + + assert.Equal(t, "bash-allowlist-unsupported-engine-guided-error", codemod.ID) + assert.NotEmpty(t, codemod.Name) + assert.NotEmpty(t, codemod.Description) + assert.Equal(t, "0.78.0", codemod.IntroducedIn) + assert.True(t, codemod.Guided, "codemod must be guided since the fix changes semantics") + assert.NotNil(t, codemod.Apply) + assert.NotNil(t, codemod.ApplyWithContext, "codemod must expose ApplyWithContext to resolve effective tools from imports") +} + +func TestBashAllowlistUnsupportedEngineCodemod_Apply(t *testing.T) { + codemod := getBashAllowlistUnsupportedEngineCodemod() + + content := `--- +on: workflow_dispatch +engine: + id: codex +tools: + bash: ["git", "npm"] +--- + +# Agent +` + + tests := []struct { + name string + frontmatter map[string]any + wantErr bool + errContains []string + }{ + { + name: "codex with restricted bash allow-list returns guided error", + frontmatter: map[string]any{ + "engine": map[string]any{"id": "codex"}, + "tools": map[string]any{"bash": []any{"git", "npm"}}, + }, + wantErr: true, + errContains: []string{"engine 'codex' does not support bash command allow-listing", `'bash: ["git", "npm"]'`, "copilot", "claude", "gemini", `bash: ["*"]`}, + }, + { + name: "codex as engine string returns guided error", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": []any{"git"}}, + }, + wantErr: true, + errContains: []string{"engine 'codex' does not support bash command allow-listing"}, + }, + { + name: "command with control characters is quoted and does not spoof output", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": []any{"git\x1b[31m status"}}, + }, + wantErr: true, + // The ANSI escape sequence must be rendered as \x1b in the error, not raw bytes. + errContains: []string{`"git\x1b[31m status"`}, + }, + { + name: "command with embedded newline is quoted and does not spoof output", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": []any{"git\nstatus"}}, + }, + wantErr: true, + errContains: []string{`"git\nstatus"`}, + }, + { + name: "codex with bash: false returns guided error", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": false}, + }, + wantErr: true, + errContains: []string{"'bash: false'"}, + }, + { + name: "codex with empty bash list returns guided error", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": []any{}}, + }, + wantErr: true, + errContains: []string{"'bash: []'"}, + }, + { + name: "codex with wildcard bash is a no-op", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": []any{"*"}}, + }, + }, + { + name: "codex with bash: true is a no-op", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"bash": true}, + }, + }, + { + name: "codex without tools.bash is a no-op", + frontmatter: map[string]any{ + "engine": "codex", + "tools": map[string]any{"edit": nil}, + }, + }, + { + name: "copilot with restricted bash allow-list is a no-op", + frontmatter: map[string]any{ + "engine": "copilot", + "tools": map[string]any{"bash": []any{"git", "npm"}}, + }, + }, + { + // No engine key → extractEngineIDFromFrontmatter returns "copilot", which does + // support BashCommandAllowlist, so no guided error is emitted. This test will + // need to be revisited if the default engine changes or loses the capability. + name: "default engine (copilot) with restricted bash allow-list is a no-op because copilot supports the capability", + frontmatter: map[string]any{ + "tools": map[string]any{"bash": []any{"git"}}, + }, + }, + { + name: "unknown engine is a no-op", + frontmatter: map[string]any{ + "engine": "not-a-real-engine", + "tools": map[string]any{"bash": []any{"git"}}, + }, + }, + { + name: "workflow without tools is a no-op", + frontmatter: map[string]any{"engine": "codex"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newContent, applied, err := codemod.Apply(content, tt.frontmatter) + assert.False(t, applied, "guided codemod never modifies the workflow") + assert.Equal(t, content, newContent, "content must be preserved") + if !tt.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + for _, expected := range tt.errContains { + assert.Contains(t, err.Error(), expected) + } + }) + } +} + +// TestBashAllowlistUnsupportedEngineCodemod_ApplyWithContext_ImportedRestriction verifies that +// ApplyWithContext detects a bash restriction that originates solely from an imported file (not +// the top-level workflow), which Apply cannot detect because it only sees raw frontmatter. +func TestBashAllowlistUnsupportedEngineCodemod_ApplyWithContext_ImportedRestriction(t *testing.T) { + codemod := getBashAllowlistUnsupportedEngineCodemod() + + dir := t.TempDir() + + // Import file that declares a restricted bash allow-list. + importContent := `--- +tools: + bash: ["git", "npm ci"] +--- +` + importPath := filepath.Join(dir, "tools-import.md") + require.NoError(t, os.WriteFile(importPath, []byte(importContent), 0o644)) + + // Main workflow file: codex engine, no top-level tools.bash, but imports the restriction. + mainContent := `--- +engine: + id: codex +imports: + - tools-import.md +--- + +# Agent +` + mainPath := filepath.Join(dir, "workflow.md") + require.NoError(t, os.WriteFile(mainPath, []byte(mainContent), 0o644)) + + frontmatter := map[string]any{ + "engine": map[string]any{"id": "codex"}, + "imports": []any{"tools-import.md"}, + } + + newContent, applied, err := codemod.ApplyWithContext(mainContent, frontmatter, mainPath) + assert.False(t, applied, "guided codemod never modifies the workflow") + assert.Equal(t, mainContent, newContent, "content must be preserved") + require.Error(t, err, "should detect bash restriction imported from shared file") + assert.Contains(t, err.Error(), "engine 'codex' does not support bash command allow-listing") +} + +// TestBashAllowlistUnsupportedEngineCodemod_ApplyWithContext_NoImportedRestriction verifies that +// ApplyWithContext is a no-op when no bash restriction exists in the top-level or imported tools. +func TestBashAllowlistUnsupportedEngineCodemod_ApplyWithContext_NoImportedRestriction(t *testing.T) { + codemod := getBashAllowlistUnsupportedEngineCodemod() + + content := `--- +engine: + id: codex +--- + +# Agent +` + frontmatter := map[string]any{ + "engine": map[string]any{"id": "codex"}, + } + + newContent, applied, err := codemod.ApplyWithContext(content, frontmatter, "") + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, newContent) +} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index 4d2bd8ac5f9..e649c16a6ec 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -18,6 +18,12 @@ type Codemod struct { IntroducedIn string // Version where this codemod was introduced Guided bool // If true, errors from Apply are guided/manual-fix errors (not auto-correctable) Apply func(content string, frontmatter map[string]any) (string, bool, error) + // ApplyWithContext is an optional extension of Apply that also receives the absolute path of the + // workflow file being processed. Codemods that need to resolve imported tools or included files + // to derive the effective configuration should set this field; fix_command.go will call it in + // preference to Apply when a file path is available. When ApplyWithContext is nil, Apply is + // used as the sole handler. + ApplyWithContext func(content string, frontmatter map[string]any, filePath string) (string, bool, error) } // GuidedError is returned when a codemod with Guided: true emits an error. @@ -65,6 +71,7 @@ func GetAllCodemods() []Codemod { getInstallScriptURLCodemod(), getBashAnonymousRemovalCodemod(), // Replace bash: with bash: false getBashSingleQuotedArgsCodemod(), // Rewrite single-quoted bash args to double-quoted form + getBashAllowlistUnsupportedEngineCodemod(), // Detect restricted tools.bash on engines that ignore it and emit guided error getActivationOutputsCodemod(), // Transform needs.activation.outputs.* to steps.sanitized.outputs.* getRolesToOnRolesCodemod(), // Move top-level roles to on.roles getBotsToOnBotsCodemod(), // Move top-level bots to on.bots diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 3c338f45b21..ce92e64d851 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -127,6 +127,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) { "sandbox-mcp-version-removal", "sandbox-agent-false-removal", "bash-single-quoted-args-rewrite", + "bash-allowlist-unsupported-engine-guided-error", "infer-to-disable-model-invocation", "run-install-scripts-to-runtimes-node", "mentions-allow-team-members-to-allowed-collaborators", @@ -206,6 +207,7 @@ func expectedCodemodOrder() []string { "install-script-url-migration", "bash-anonymous-removal", "bash-single-quoted-args-rewrite", + "bash-allowlist-unsupported-engine-guided-error", "activation-outputs-to-sanitized-step", "roles-to-on-roles", "bots-to-on-bots", diff --git a/pkg/cli/fix_command.go b/pkg/cli/fix_command.go index 093afe68824..1ade9faf991 100644 --- a/pkg/cli/fix_command.go +++ b/pkg/cli/fix_command.go @@ -324,7 +324,12 @@ func processWorkflowFileWithInfo(filePath string, codemods []Codemod, write bool continue } - newContent, applied, err := codemod.Apply(currentContent, currentResult.Frontmatter) + newContent, applied, err := func() (string, bool, error) { + if codemod.ApplyWithContext != nil { + return codemod.ApplyWithContext(currentContent, currentResult.Frontmatter, filePath) + } + return codemod.Apply(currentContent, currentResult.Frontmatter) + }() if err != nil { fixLog.Printf("Codemod %s failed: %v", codemod.ID, err) wrappedErr := fmt.Errorf("codemod %s failed: %w", codemod.ID, err) diff --git a/pkg/workflow/agent_validation.go b/pkg/workflow/agent_validation.go index 86982ad17aa..69546df764b 100644 --- a/pkg/workflow/agent_validation.go +++ b/pkg/workflow/agent_validation.go @@ -267,7 +267,7 @@ func (c *Compiler) validateBashCommandAllowlistSupport(tools map[string]any, eng if capabilities.BashCommandAllowlist { return nil } - if !hasBashExplicitRestriction(tools) { + if !HasBashExplicitRestriction(tools) { return nil } if capabilities.BashDisable && hasBashFullyDisabled(tools) { @@ -284,12 +284,12 @@ func (c *Compiler) validateBashCommandAllowlistSupport(tools map[string]any, eng engine.GetID()) } -// hasBashExplicitRestriction reports true when the tools map contains a bash configuration +// 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 only. +// 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 { +func HasBashExplicitRestriction(tools map[string]any) bool { if tools == nil { return false } diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 5a3f5bded32..7798f93ff93 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -608,6 +608,20 @@ func (r *EngineRegistry) GetEngine(id string) (CodingAgentEngine, error) { return engine, nil } +// EnginesWithCapability returns a sorted list of engine IDs for which the given capability +// predicate returns true. It is used to build accurate, registry-driven lists of supported +// engines in error messages and documentation so those lists stay correct as engines evolve. +func (r *EngineRegistry) EnginesWithCapability(predicate func(EngineCapabilities) bool) []string { + var ids []string + for id, engine := range r.engines { + if predicate(engine.GetCapabilities()) { + ids = append(ids, id) + } + } + sort.Strings(ids) + return ids +} + // GetSupportedEngines returns a list of all supported engine IDs func (r *EngineRegistry) GetSupportedEngines() []string { agenticEngineLog.Print("Getting list of supported engines")