-
Notifications
You must be signed in to change notification settings - Fork 485
Add guided gh aw fix diagnostic for restricted tools.bash on engines that ignore allow-listing
#51102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add guided gh aw fix diagnostic for restricted tools.bash on engines that ignore allow-listing
#51102
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
5175c41
Initial plan
Copilot 8d51ba6
Initial plan for codex bash allow-list codemod
Copilot 546ecea
Add guided codemod for restricted tools.bash on engines that ignore it
Copilot c6d3d78
docs(adr): draft ADR-51102 for guided bash allow-list codemod on unsu…
github-actions[bot] a2d1e43
Merge branch 'main' into copilot/aw-compat-missing-codemod-fix
github-actions[bot] cff3287
Merge branch 'main' into copilot/aw-compat-missing-codemod-fix
pelikhan e6ac777
Merge branch 'main' into copilot/aw-compat-missing-codemod-fix
pelikhan ee97e67
Fix bash allowlist codemod: quote commands with %q and resolve effect…
Copilot 0276f28
Address github-actions bot review threads: dynamic engine list, doubl…
Copilot af5b893
Merge branch 'main' into copilot/aw-compat-missing-codemod-fix
github-actions[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
44 changes: 44 additions & 0 deletions
44
docs/adr/51102-guided-codemod-for-bash-allowlist-on-unsupported-engines.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.* |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <!-- include: ... --> 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" | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.