Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions docs/fixes/2026-04-01-ai-tool-unvalidated-subcommand-arguments.md
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
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language tag to this fenced example.

Line 22 currently trips MD040. text or shell is 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
Verify each finding against the current code and only fix it if needed.

In `@docs/fixes/2026-04-01-ai-tool-unvalidated-subcommand-arguments.md` around
lines 22 - 26, The fenced code block containing the Terraform/shell commands
needs a language tag to satisfy MD040; update the opening fence from ``` to
```shell (or ```text) so the block is annotated (the block showing "terraform
apply vpc -s prod -var-file=/etc/passwd" / "terraform destroy ..." / "terraform
state rm ...").


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.
1 change: 1 addition & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@ var (
ErrAICommandEmpty = errors.New("command cannot be empty")
ErrAICommandBlacklisted = errors.New("command is blacklisted for security reasons")
ErrAICommandRmNotAllowed = errors.New("rm with recursive or force flags is not allowed")
ErrAICommandDestructive = errors.New("command modifies state and is not permitted in the current permission mode; use ModePrompt with explicit user confirmation")
ErrAILSPNotEnabled = errors.New("LSP is not enabled")
ErrAIFileDoesNotExist = errors.New("file does not exist")
ErrAIConfigNil = errors.New("cannot switch provider: atmosConfig is nil")
Expand Down
98 changes: 89 additions & 9 deletions pkg/ai/tools/atmos/execute_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Thread the real permission mode into the default constructor path.

pkg/ai/tools/atmos/setup.go still registers this tool via NewExecuteAtmosCommandTool(atmosConfig), which hard-codes permissionMode to ModePrompt. In ModeAllow and ModeYOLO, the global checker auto-allows the tool, so destructive commands still skip this new block and execute.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/ai/tools/atmos/execute_command.go` around lines 54 - 65, The default
constructor NewExecuteAtmosCommandTool currently hard-codes permissionMode to
permission.ModePrompt; thread the real permission into that path by changing
NewExecuteAtmosCommandTool to accept a permission.Mode (or have it call
NewExecuteAtmosCommandToolWithPermission) and set
ExecuteAtmosCommandTool.permissionMode from the passed mode, then update the
registration in setup.go to pass the actual runtime permission mode (the
global/checker-provided mode) when constructing the tool so ModeAllow/ModeYOLO
cases are respected; also update NewExecuteAtmosCommandToolWithPermission to
delegate to the new signature to avoid duplication.

}

// 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."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The description overstates what “always allowed” means.

RequiresPermission() still returns true, and the global checker still prompts in ModePrompt or denies in ModeDeny. Calling safe commands “always allowed” will mislead the model about the actual execution flow; I’d scope this to “not blocked by this validator” instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/ai/tools/atmos/execute_command.go` around lines 75 - 81, The comment text
incorrectly says safe read-only commands are "always allowed"; update the
description returned by the function that builds the help/description (the
string literal in execute_command.go) to say these commands are "not blocked by
this validator" (or similar) and clarify that RequiresPermission() still returns
true and the global permission checker (ModePrompt/ModeDeny) will still prompt
or deny as appropriate; reference RequiresPermission(), ModePrompt and ModeDeny
in the updated sentence so readers know the validator only skips blocking them,
not bypassing global permission checks.

}

// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does spf13/cobra accept persistent flags before subcommands, e.g. can terraform -s prod apply vpcorterraform --stack prod state rm module.vpcparse withapply/state as the subcommand after the parent flag?

💡 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 #10 in Cobra repo fixed early flag parsing issues, enabling this POSIX-style flexibility.

Citations:


🏁 Script executed:

# Find and examine the execute_command.go file
fd execute_command.go -type f

Repository: cloudposse/atmos

Length of output: 230


🏁 Script executed:

# Check how isDestructiveAtmosCommand is called
rg "isDestructiveAtmosCommand" -A 3 -B 3

Repository: cloudposse/atmos

Length of output: 4948


🏁 Script executed:

# Look at cmd/terraform.go to see what persistent flags are defined
head -200 cmd/terraform.go

Repository: cloudposse/atmos

Length of output: 135


🏁 Script executed:

# Read the execute_command.go file
cat -n pkg/ai/tools/atmos/execute_command.go

Repository: 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 -150

Repository: cloudposse/atmos

Length of output: 5757


🏁 Script executed:

# Find terraform command definition
fd terraform.go

Repository: 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 -100

Repository: 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 5

Repository: 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 -80

Repository: 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 -5

Repository: 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.md

Repository: cloudposse/atmos

Length of output: 7426


Handle flags preceding subcommands in classification.

The current implementation assumes the destructive subcommand is always at args[1] or args[2], but strings.Fields() does simple whitespace splitting with no flag parsing. Commands like terraform -s prod apply vpc result in args=["terraform", "-s", "prod", "apply", "vpc"], causing isDestructiveAtmosCommand to check args[1]="-s" instead of the actual subcommand at args[3]. This allows destructive operations to bypass the blocker.

Fix by either:

  1. Stripping leading flags before checking subcommand position, or
  2. Reusing the actual CLI parser for the terraform subcommand shape instead of positional assumptions.

Test cases should cover terraform -s prod apply vpc, terraform --stack prod state rm module.vpc, and similar patterns to prevent regression.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/ai/tools/atmos/execute_command.go` around lines 96 - 123,
isDestructiveAtmosCommand assumes subcommands live at fixed positions (args[1],
args[2]) which breaks when flags precede the subcommand (e.g., terraform -s prod
apply ...); update isDestructiveAtmosCommand to locate the real subcommand by
first confirming args[0] == "terraform" then skipping any leading flags (tokens
that start with "-") to find the first non-flag token as subCmd, and when
handling nested verbs like "state" or "workspace" similarly advance past any
flags to find the nested subcommand (use the existing
destructiveTerraformSubcmds, destructiveTerraformStateSubcmds,
destructiveTerraformWorkspaceSubcmds lookups); add tests for cases like
"terraform -s prod apply vpc" and "terraform --stack prod state rm module.vpc"
to prevent regressions.


// 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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading