-
-
Notifications
You must be signed in to change notification settings - Fork 153
fix: block unvalidated subcommand arguments in execute_atmos_command AI tool (CWE-88) #2275
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
base: main
Are you sure you want to change the base?
Changes from 4 commits
edfa971
71b1f7e
f9a6721
9d81419
07bbbad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| # AI Tool `execute_atmos_command` Accepts Unvalidated Subcommand Arguments (CWE-88) | ||
|
|
||
| **Date:** 2026-04-01 | ||
|
|
||
| **Vulnerability Class:** CWE-88 — Improper Neutralization of Argument Delimiters in a Command | ||
|
|
||
| **Severity:** High | ||
|
|
||
| **Affected File:** `pkg/ai/tools/atmos/execute_command.go`, lines 70–90 (prior to fix) | ||
|
|
||
| --- | ||
|
|
||
| ## Issue Description | ||
|
|
||
| The `execute_atmos_command` AI tool split the incoming `command` parameter with | ||
| `strings.Fields()` and forwarded the resulting tokens directly to the Atmos binary | ||
| as arguments, with no validation of which subcommands or flags were being requested. | ||
|
|
||
| An AI agent under a prompt-injection or LLM-jacking attack could be induced to run | ||
| state-modifying Terraform operations such as: | ||
|
|
||
| ``` | ||
| terraform apply vpc -s prod -var-file=/etc/passwd | ||
| terraform destroy vpc -s prod --auto-approve | ||
| terraform state rm module.vpc | ||
| ``` | ||
|
|
||
| Because the default permission mode was `ModeAllow` (i.e. no user confirmation), these | ||
| operations could be triggered automatically without any human gate. | ||
|
|
||
| ### Attack Scenario | ||
|
|
||
| 1. An adversary crafts a prompt that instructs the LLM to call | ||
| `execute_atmos_command` with `terraform apply vpc -s prod`. | ||
| 2. Because no subcommand validation exists, the tool passes the args directly to the | ||
| Atmos binary. | ||
| 3. With `ModeAllow`, the permission system auto-approves the call. | ||
| 4. Production infrastructure is modified without operator awareness. | ||
|
|
||
| --- | ||
|
|
||
| ## Root Cause Analysis | ||
|
|
||
| ### No Subcommand Allowlist | ||
|
|
||
| `Execute()` called `strings.Fields(command)` to split the user-controlled string into | ||
| args, then passed the result verbatim to `exec.CommandContext`. There was no inspection | ||
| of `args[0]` or `args[1]` to determine whether the requested operation was read-only or | ||
| state-modifying. | ||
|
|
||
| ```go | ||
| // Before fix — unsafe: | ||
| args := strings.Fields(command) | ||
| cmd := exec.CommandContext(ctx, t.binaryPath, args...) | ||
| ``` | ||
|
|
||
| ### No Permission-Mode Gate at the Tool Layer | ||
|
|
||
| The `execute_atmos_command` tool delegated all access control to the upstream permission | ||
| system (`pkg/ai/tools/permission`). When that system was configured to `ModeAllow` | ||
| (auto-approve), any command — including destructive ones — could run without prompting | ||
| the user. | ||
|
|
||
| --- | ||
|
|
||
| ## Fix | ||
|
|
||
| ### 1. Subcommand Blocklists | ||
|
|
||
| Three static blocklists were added covering all Terraform operations that modify | ||
| infrastructure or workspace state: | ||
|
|
||
| | Blocklist | Entries | | ||
| |---|---| | ||
| | `destructiveTerraformSubcmds` | `apply`, `destroy`, `import`, `force-unlock` | | ||
| | `destructiveTerraformStateSubcmds` | `state rm`, `state mv`, `state push` | | ||
| | `destructiveTerraformWorkspaceSubcmds` | `workspace new`, `workspace delete` | | ||
|
|
||
| ### 2. `isDestructiveAtmosCommand()` Helper | ||
|
|
||
| A new helper function inspects the first 1–3 tokens of the parsed args to determine | ||
| whether the requested operation is state-modifying. Matching is case-insensitive | ||
| (`strings.ToLower`) so `TERRAFORM APPLY` is treated identically to `terraform apply`. | ||
|
|
||
| ```go | ||
| func isDestructiveAtmosCommand(args []string) bool { | ||
| if len(args) < 2 || strings.ToLower(args[0]) != "terraform" { | ||
| return false | ||
| } | ||
| subCmd := strings.ToLower(args[1]) | ||
| if destructiveTerraformSubcmds[subCmd] { return true } | ||
| if subCmd == "state" && len(args) >= 3 { | ||
| return destructiveTerraformStateSubcmds[strings.ToLower(args[2])] | ||
| } | ||
| if subCmd == "workspace" && len(args) >= 3 { | ||
| return destructiveTerraformWorkspaceSubcmds[strings.ToLower(args[2])] | ||
| } | ||
| return false | ||
| } | ||
| ``` | ||
|
|
||
| ### 3. Permission-Mode Gate in `Execute()` | ||
|
|
||
| The `ExecuteAtmosCommandTool` struct gains a `permissionMode` field (default: | ||
| `permission.ModePrompt`, the safest setting). Before spawning any subprocess, `Execute()` | ||
| checks whether the requested command is destructive and whether the current mode permits | ||
| it: | ||
|
|
||
| - **`ModeAllow`, `ModeDeny`, `ModeYOLO`** — state-modifying commands are rejected | ||
| immediately with `ErrAICommandDestructive`, before any subprocess is created. | ||
| - **`ModePrompt`** — state-modifying commands are forwarded to the upstream permission | ||
| system, which presents an explicit confirmation prompt to the operator. The subprocess | ||
| is only created if the operator approves. | ||
|
|
||
| ```go | ||
| if isDestructiveAtmosCommand(args) { | ||
| if t.permissionMode != permission.ModePrompt { | ||
| // Blocked — no subprocess created. | ||
| return &tools.Result{ | ||
| Success: false, | ||
| Error: fmt.Errorf("%w: atmos %s", errUtils.ErrAICommandDestructive, command), | ||
| }, nil | ||
| } | ||
| // ModePrompt: forwarded to upstream permission checker for user confirmation. | ||
| log.Warnf("Destructive Atmos command will require user confirmation: atmos %s", command) | ||
| } | ||
| ``` | ||
|
|
||
| ### 4. New Constructor and Safe Default | ||
|
|
||
| `NewExecuteAtmosCommandToolWithPermission(cfg, mode)` allows callers to configure the | ||
| permission mode explicitly. The existing `NewExecuteAtmosCommandTool(cfg)` constructor | ||
| now defaults to `ModePrompt` (previously there was no mode field at all). | ||
|
|
||
| --- | ||
|
|
||
| ## Files Modified | ||
|
|
||
| | File | Change | | ||
| |---|---| | ||
| | `errors/errors.go` | Added `ErrAICommandDestructive` sentinel error | | ||
| | `pkg/ai/tools/atmos/execute_command.go` | Added blocklists, `isDestructiveAtmosCommand()`, `permissionMode` field, gate in `Execute()`, new constructor, updated description | | ||
| | `pkg/ai/tools/atmos/execute_command_test.go` | Added 87 test cases covering classification, blocking, pass-through, and safe-command scenarios | | ||
|
|
||
| --- | ||
|
|
||
| ## Test Coverage | ||
|
|
||
| ### Classification — `TestIsDestructiveAtmosCommand` (28 cases) | ||
|
|
||
| Covers all blocked subcommands, safe read-only subcommands, case-insensitive matching, | ||
| and edge cases (empty args, single token, compound subcommands without a secondary token). | ||
|
|
||
| ### Blocking — `TestExecuteAtmosCommandTool_DestructiveBlocked` (27 cases) | ||
|
|
||
| Verifies that each of the 9 destructive command patterns is blocked across all three | ||
| non-prompt modes (`ModeAllow`, `ModeDeny`, `ModeYOLO`). Confirms that: | ||
|
|
||
| - `result.Success` is `false`. | ||
| - `result.Error` contains `"modifies state"`. | ||
| - The subprocess binary is never reached (the test binary is set as `binaryPath` but the | ||
| validator fires before any invocation). | ||
|
|
||
| ### Pass-Through — `TestExecuteAtmosCommandTool_DestructiveAllowedInPromptMode` (1 case) | ||
|
|
||
| Verifies that `terraform apply` is **not** blocked by the validator in `ModePrompt`, | ||
| confirming the command reaches the execution stage (where the upstream permission system | ||
| takes over for user confirmation). | ||
|
|
||
| ### Safe Commands — `TestExecuteAtmosCommandTool_SafeCommandsAlwaysAllowed` (32 cases) | ||
|
|
||
| Verifies that read-only commands (`terraform plan`, `terraform show`, `terraform output`, | ||
| `terraform validate`, `terraform state list`, `terraform workspace list`, `describe stacks`, | ||
| `list stacks`) are never blocked by the validator in any permission mode. | ||
|
|
||
| --- | ||
|
|
||
| ## Backward Compatibility | ||
|
|
||
| - `NewExecuteAtmosCommandTool(cfg)` now defaults to `ModePrompt` instead of having no | ||
| mode field. Callers that previously relied on `ModeAllow` to auto-execute destructive | ||
| commands without confirmation will need to switch to | ||
| `NewExecuteAtmosCommandToolWithPermission(cfg, permission.ModePrompt)` and accept the | ||
| user confirmation prompt — or reconsider whether such automation is appropriate. | ||
| - Safe (read-only) commands are unaffected in all modes. | ||
| - The `execute_atmos_command` tool description now documents the restriction and the | ||
| confirmation flow so AI agents have accurate information about what is permitted. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,14 +9,38 @@ import ( | |
|
|
||
| errUtils "github.com/cloudposse/atmos/errors" | ||
| "github.com/cloudposse/atmos/pkg/ai/tools" | ||
| "github.com/cloudposse/atmos/pkg/ai/tools/permission" | ||
| log "github.com/cloudposse/atmos/pkg/logger" | ||
| "github.com/cloudposse/atmos/pkg/schema" | ||
| ) | ||
|
|
||
| // destructiveTerraformSubcmds is the set of Terraform subcommands that modify infrastructure state. | ||
| // These operations are never auto-executed; they require ModePrompt with explicit user confirmation. | ||
| var destructiveTerraformSubcmds = map[string]bool{ | ||
| "apply": true, | ||
| "destroy": true, | ||
| "import": true, | ||
| "force-unlock": true, | ||
| } | ||
|
|
||
| // destructiveTerraformStateSubcmds is the set of "terraform state" subcommands that modify state. | ||
| var destructiveTerraformStateSubcmds = map[string]bool{ | ||
| "rm": true, | ||
| "mv": true, | ||
| "push": true, | ||
| } | ||
|
|
||
| // destructiveTerraformWorkspaceSubcmds is the set of "terraform workspace" subcommands that modify state. | ||
| var destructiveTerraformWorkspaceSubcmds = map[string]bool{ | ||
| "new": true, | ||
| "delete": true, | ||
| } | ||
|
|
||
| // ExecuteAtmosCommandTool executes any Atmos CLI command. | ||
| type ExecuteAtmosCommandTool struct { | ||
| atmosConfig *schema.AtmosConfiguration | ||
| binaryPath string | ||
| atmosConfig *schema.AtmosConfiguration | ||
| binaryPath string | ||
| permissionMode permission.Mode | ||
| } | ||
|
|
||
| // NewExecuteAtmosCommandTool creates a new Atmos command execution tool. | ||
|
|
@@ -28,33 +52,76 @@ func NewExecuteAtmosCommandTool(atmosConfig *schema.AtmosConfiguration) *Execute | |
| } | ||
|
|
||
| return &ExecuteAtmosCommandTool{ | ||
| atmosConfig: atmosConfig, | ||
| binaryPath: binary, | ||
| atmosConfig: atmosConfig, | ||
| binaryPath: binary, | ||
| permissionMode: permission.ModePrompt, // default to safest mode | ||
| } | ||
| } | ||
|
|
||
| // NewExecuteAtmosCommandToolWithPermission creates a new Atmos command execution tool with explicit permission mode. | ||
| func NewExecuteAtmosCommandToolWithPermission(atmosConfig *schema.AtmosConfiguration, mode permission.Mode) *ExecuteAtmosCommandTool { | ||
| t := NewExecuteAtmosCommandTool(atmosConfig) | ||
| t.permissionMode = mode | ||
| return t | ||
|
Comment on lines
56
to
+83
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thread the real permission mode into the default constructor path.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Name returns the tool name. | ||
| func (t *ExecuteAtmosCommandTool) Name() string { | ||
| return "execute_atmos_command" | ||
| } | ||
|
|
||
| // Description returns the tool description. | ||
| func (t *ExecuteAtmosCommandTool) Description() string { | ||
| return "LAST RESORT: Execute an Atmos CLI command as a subprocess. Only use this for commands that do NOT have a dedicated tool. Do NOT use this for: listing stacks (use atmos_list_stacks), describing components (use atmos_describe_component), describing affected (use atmos_describe_affected), or validating stacks (use atmos_validate_stacks). Use this only for commands like 'terraform plan', 'terraform apply', 'workflow', etc." | ||
| return "LAST RESORT: Execute an Atmos CLI command as a subprocess. " + | ||
| "Only use this for commands that do NOT have a dedicated tool. " + | ||
| "Do NOT use this for: listing stacks (use atmos_list_stacks), describing components (use atmos_describe_component), " + | ||
| "describing affected (use atmos_describe_affected), or validating stacks (use atmos_validate_stacks). " + | ||
| "Safe read-only commands (terraform plan, show, output, validate, state list, workspace list, describe, list) are always allowed. " + | ||
| "State-modifying operations (terraform apply, destroy, import, force-unlock, state rm/mv/push, workspace new/delete) " + | ||
| "require ModePrompt for user confirmation; all other modes block them at the tool layer." | ||
|
||
| } | ||
|
|
||
| // Parameters returns the tool parameters. | ||
| func (t *ExecuteAtmosCommandTool) Parameters() []tools.Parameter { | ||
| return []tools.Parameter{ | ||
| { | ||
| Name: "command", | ||
| Description: "The Atmos command to execute (without the 'atmos' prefix). Examples: 'terraform plan vpc -s prod-us-east-1', 'terraform apply vpc -s prod-us-east-1', 'workflow deploy'.", | ||
| Description: "The Atmos command to execute (without the 'atmos' prefix). Examples: 'terraform plan vpc -s prod-us-east-1', 'terraform show vpc -s prod-us-east-1', 'workflow deploy'. State-modifying commands (apply, destroy, import, etc.) require ModePrompt permission mode.", | ||
| Type: tools.ParamTypeString, | ||
| Required: true, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // isDestructiveAtmosCommand reports whether args represent a state-modifying Terraform operation. | ||
| func isDestructiveAtmosCommand(args []string) bool { | ||
| if len(args) < 2 { | ||
| return false | ||
| } | ||
|
|
||
| if strings.ToLower(args[0]) != "terraform" { | ||
| return false | ||
| } | ||
|
|
||
| subCmd := strings.ToLower(args[1]) | ||
|
|
||
| if destructiveTerraformSubcmds[subCmd] { | ||
| return true | ||
| } | ||
|
|
||
| if subCmd == "state" && len(args) >= 3 { | ||
| stateSubCmd := strings.ToLower(args[2]) | ||
| return destructiveTerraformStateSubcmds[stateSubCmd] | ||
| } | ||
|
|
||
| if subCmd == "workspace" && len(args) >= 3 { | ||
| wsSubCmd := strings.ToLower(args[2]) | ||
| return destructiveTerraformWorkspaceSubcmds[wsSubCmd] | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
Comment on lines
+140
to
+172
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, spf13/cobra accepts persistent flags before subcommands. Cobra's parsing algorithm first strips flags from the argument list using the command tree's flags (including persistent flags from parents), then matches the remaining positional arguments to subcommands. This allows persistent flags anywhere before/after the subcommand name, as long as they are valid for the final command path. For example, terraform -s prod apply vpc parses if -s is a persistent flag on root (available to apply), with apply and vpc as args to apply. Similarly for --stack prod state rm module.vpc, assuming --stack is persistent. Terraform uses Cobra and supports global options like -chdir before subcommands, confirming this behavior (e.g., terraform -chdir=foo plan). Issue Citations:
🏁 Script executed: # Find and examine the execute_command.go file
fd execute_command.go -type fRepository: cloudposse/atmos Length of output: 230 🏁 Script executed: # Check how isDestructiveAtmosCommand is called
rg "isDestructiveAtmosCommand" -A 3 -B 3Repository: cloudposse/atmos Length of output: 4948 🏁 Script executed: # Look at cmd/terraform.go to see what persistent flags are defined
head -200 cmd/terraform.goRepository: cloudposse/atmos Length of output: 135 🏁 Script executed: # Read the execute_command.go file
cat -n pkg/ai/tools/atmos/execute_command.goRepository: cloudposse/atmos Length of output: 8075 🏁 Script executed: # Check the test file to understand how args are passed
cat -n pkg/ai/tools/atmos/execute_command_test.go | head -150Repository: cloudposse/atmos Length of output: 5757 🏁 Script executed: # Find terraform command definition
fd terraform.goRepository: cloudposse/atmos Length of output: 139 🏁 Script executed: # Check more test cases to see if there are cases with flags before subcommands
cat -n pkg/ai/tools/atmos/execute_command_test.go | tail -100Repository: cloudposse/atmos Length of output: 4199 🏁 Script executed: # Look for how command strings are constructed - check if there's any parsing/normalization
rg "strings.Fields" pkg/ai/tools/atmos/execute_command.go -B 5 -A 5Repository: cloudposse/atmos Length of output: 297 🏁 Script executed: # Check if there are test cases that would expose the issue
rg "apply\|state rm\|workspace" pkg/ai/tools/atmos/execute_command_test.go -B 2 -A 2 | head -80Repository: cloudposse/atmos Length of output: 42 🏁 Script executed: # Check documentation or examples for terraform command usage
rg "terraform.*apply\|terraform.*destroy\|terraform.*state" docs/ -l | head -5Repository: cloudposse/atmos Length of output: 42 🏁 Script executed: # Look at the fix document to see if this was addressed
cat docs/fixes/2026-04-01-ai-tool-unvalidated-subcommand-arguments.mdRepository: cloudposse/atmos Length of output: 7426 Handle flags preceding subcommands in classification. The current implementation assumes the destructive subcommand is always at Fix by either:
Test cases should cover 🤖 Prompt for AI Agents |
||
|
|
||
| // Execute runs the Atmos command and returns the output. | ||
| func (t *ExecuteAtmosCommandTool) Execute(ctx context.Context, params map[string]interface{}) (*tools.Result, error) { | ||
| // Extract command parameter. | ||
|
|
@@ -77,6 +144,20 @@ func (t *ExecuteAtmosCommandTool) Execute(ctx context.Context, params map[string | |
| }, nil | ||
| } | ||
|
|
||
| // Validate subcommand: block state-modifying operations unless the permission mode | ||
| // explicitly requires user confirmation (ModePrompt). This prevents prompt-injection | ||
| // and LLM-jacking attacks from triggering destructive operations automatically. | ||
| if isDestructiveAtmosCommand(args) { | ||
| if t.permissionMode != permission.ModePrompt { | ||
| log.Warnf("Blocked destructive Atmos command in non-interactive mode: atmos %s", command) | ||
| return &tools.Result{ | ||
| Success: false, | ||
| Error: fmt.Errorf("%w: atmos %s", errUtils.ErrAICommandDestructive, command), | ||
| }, nil | ||
| } | ||
| log.Warnf("Destructive Atmos command will require user confirmation: atmos %s", command) | ||
| } | ||
|
|
||
| // Create the command using the resolved binary path. | ||
| cmd := exec.CommandContext(ctx, t.binaryPath, args...) //nolint:gosec // binaryPath is resolved from os.Executable() at construction time, not user input. | ||
| cmd.Dir = t.atmosConfig.BasePath | ||
|
|
@@ -108,7 +189,6 @@ func (t *ExecuteAtmosCommandTool) RequiresPermission() bool { | |
|
|
||
| // IsRestricted returns true if this tool is always restricted. | ||
| func (t *ExecuteAtmosCommandTool) IsRestricted() bool { | ||
| // Check if this is a destructive command. | ||
| // Apply, destroy, and workflow commands are always restricted. | ||
| return false // Permission system will handle per-command restrictions. | ||
| // Permission system will handle per-command restrictions. | ||
| return false | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language tag to this fenced example.
Line 22 currently trips MD040.
textorshellis enough here.🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 22-22: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents