Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 6 additions & 6 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.26.0

require (
github.com/azure/azure-dev/cli/azd v0.0.0-20260228002641-8f080b39d69b
github.com/jongio/azd-core v0.5.3
github.com/jongio/azd-core v0.5.4
github.com/magefile/mage v1.15.0
github.com/mark3labs/mcp-go v0.43.2
github.com/spf13/cobra v1.10.2
Expand Down Expand Up @@ -83,12 +83,12 @@ require (
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
Expand Down
24 changes: 12 additions & 12 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/
github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jongio/azd-core v0.5.3 h1:gCPJs61kUCdRYZZq4Dy4Ncz+S+00rDr9jt5CPpMgBrE=
github.com/jongio/azd-core v0.5.3/go.mod h1:jQCP+px3Pxb3B0fyShfvSVa3KsWT1j2jGXMsPpQezlI=
github.com/jongio/azd-core v0.5.4 h1:pCGn+Q+NJ4pJFsojqf0sn5osTHW8mMRzD3O6SjnlFKc=
github.com/jongio/azd-core v0.5.4/go.mod h1:XeXQJVNJ/+GbBfsYIyDdJ6E3SHCrKlEqnv6eDVqDKQ4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
Expand Down Expand Up @@ -240,17 +240,17 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand All @@ -264,18 +264,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand Down
55 changes: 51 additions & 4 deletions cli/src/cmd/exec/commands/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/jongio/azd-core/azdextutil"
"github.com/jongio/azd-core/keyvault"
"github.com/jongio/azd-core/security"
"github.com/jongio/azd-core/shellutil"
"github.com/jongio/azd-exec/cli/src/internal/version"
Expand Down Expand Up @@ -176,7 +177,10 @@ func handleExecScript(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolR

cmdArgs := buildShellArgs(shell, validPath, false, scriptArgs)
cmd := exec.CommandContext(execCtx, cmdArgs[0], cmdArgs[1:]...)
cmd.Env = os.Environ()

// Resolve Key Vault references in environment variables, matching the
// CLI execution path behavior. Continue on error (best-effort).
cmd.Env = prepareEnvironmentForMCP(ctx)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
Expand Down Expand Up @@ -214,7 +218,10 @@ func handleExecInline(ctx context.Context, args azdext.ToolArgs) (*mcp.CallToolR

cmdArgs := buildShellArgs(shell, command, true, nil)
cmd := exec.CommandContext(execCtx, cmdArgs[0], cmdArgs[1:]...)
cmd.Env = os.Environ()

// Resolve Key Vault references in environment variables, matching the
// CLI execution path behavior. Continue on error (best-effort).
cmd.Env = prepareEnvironmentForMCP(ctx)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
Expand Down Expand Up @@ -283,8 +290,13 @@ func handleGetEnvironment(_ context.Context, _ azdext.ToolArgs) (*mcp.CallToolRe
continue
}

// Exclude known secret-bearing variable names
secretPatterns := []string{"SECRET", "PASSWORD", "KEY", "TOKEN", "CREDENTIAL", "CERTIFICATE", "CONNECTION_STRING", "CONNSTR"}
// Exclude known secret-bearing variable names.
// This denylist covers common Azure, cloud, and application secret patterns.
secretPatterns := []string{
"SECRET", "PASSWORD", "KEY", "TOKEN", "CREDENTIAL", "CERTIFICATE",
"CONNECTION_STRING", "CONNSTR", "PAT", "SAS", "SIGNING",
"PRIVATE", "PASSPHRASE", "AUTH",
}
isSecret := false
upperName := strings.ToUpper(name)
for _, pattern := range secretPatterns {
Expand All @@ -306,6 +318,41 @@ func handleGetEnvironment(_ context.Context, _ azdext.ToolArgs) (*mcp.CallToolRe

// --- Helpers ---

// prepareEnvironmentForMCP resolves Key Vault references in environment variables.
// This mirrors the CLI execution path (executor.prepareEnvironment) to ensure
// consistent behavior between CLI and MCP invocations. Operates in best-effort
// mode: if resolution fails, the original environment is returned unchanged.
func prepareEnvironmentForMCP(ctx context.Context) []string {
envVars := os.Environ()

// Quick check: skip resolver setup if no Key Vault references are present.
hasRef := false
for _, envVar := range envVars {
if parts := strings.SplitN(envVar, "=", 2); len(parts) == 2 && keyvault.IsKeyVaultReference(parts[1]) {
hasRef = true
break
}
}
if !hasRef {
return envVars
}

resolver, err := keyvault.NewKeyVaultResolver()
if err != nil {
// Cannot create resolver — fall back to raw environment.
fmt.Fprintf(os.Stderr, "Warning: failed to create Key Vault resolver: %v\n", err)
return envVars
}

resolved, _, err := resolver.ResolveEnvironmentVariables(ctx, envVars, keyvault.ResolveEnvironmentOptions{StopOnError: false})
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Key Vault resolution error: %v\n", err)
return envVars
}

return resolved
}

type execResult struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Expand Down
1 change: 0 additions & 1 deletion cli/src/cmd/exec/commands/version_integration_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
//go:build integration
// +build integration

package commands

Expand Down
7 changes: 5 additions & 2 deletions cli/src/cmd/exec/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type scriptExecutor interface {
ExecuteInline(ctx context.Context, scriptContent string) error
}

var newScriptExecutor = func(config executor.Config) scriptExecutor {
var newScriptExecutor = func(config executor.Config) (scriptExecutor, error) {
return executor.New(config)
}

Expand Down Expand Up @@ -75,12 +75,15 @@ Examples:
}

// Create executor
exec := newScriptExecutor(executor.Config{
exec, err := newScriptExecutor(executor.Config{
Shell: shell,
Interactive: interactive,
StopOnKeyVaultError: stopOnKeyVaultError,
Args: scriptArgs,
})
if err != nil {
return fmt.Errorf("invalid configuration: %w", err)
}

// Check if input is a file or inline script
// Try to resolve as file path first
Expand Down
8 changes: 4 additions & 4 deletions cli/src/cmd/exec/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ func TestRunE_DispatchesFileOrInline(t *testing.T) {
defer func() { newScriptExecutor = oldNew }()

fake := &fakeExecutor{}
newScriptExecutor = func(cfg executor.Config) scriptExecutor {
newScriptExecutor = func(cfg executor.Config) (scriptExecutor, error) {
fake.args = append([]string{}, cfg.Args...)
return fake
return fake, nil
}

// Avoid changing env/cwd during Execute.
Expand Down Expand Up @@ -150,9 +150,9 @@ func TestRunE_AllowsPassthroughArgsWithoutDoubleDash(t *testing.T) {
defer func() { newScriptExecutor = oldNew }()

fake := &fakeExecutor{}
newScriptExecutor = func(cfg executor.Config) scriptExecutor {
newScriptExecutor = func(cfg executor.Config) (scriptExecutor, error) {
fake.args = append([]string{}, cfg.Args...)
return fake
return fake, nil
}

// Avoid changing env/cwd during Execute.
Expand Down
26 changes: 19 additions & 7 deletions cli/src/internal/executor/command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,44 @@ var validShells = map[string]bool{
// - Windows cmd: Use /c for both inline and files
// - Unknown shells: Fall back to -c flag (Unix-like behavior)
//
// Known shell names are normalized to lowercase for the executable binary
// to ensure correct lookup on case-sensitive filesystems.
// Script arguments (e.config.Args) are appended after the script specification.
func (e *Executor) buildCommand(shell, scriptOrPath string, isInline bool) *exec.Cmd {
var cmdArgs []string
skipAppendArgs := false

// Normalize shell name to lowercase for comparison
// Normalize shell name to lowercase for both comparison and executable name.
// This ensures correct binary lookup on case-sensitive filesystems
// (e.g., --shell BASH resolves to "bash", not "BASH").
shellLower := strings.ToLower(shell)

// Use the lowercase name for known shells; keep original for unknown shells
// (custom interpreters like "Python3" should preserve user's casing).
shellBin := shell
if validShells[shellLower] {
shellBin = shellLower
}

switch shellLower {
case shellutil.ShellBash, shellutil.ShellSh, shellutil.ShellZsh:
if isInline {
cmdArgs = []string{shell, "-c", scriptOrPath}
cmdArgs = []string{shellBin, "-c", scriptOrPath}
} else {
cmdArgs = []string{shell, scriptOrPath}
cmdArgs = []string{shellBin, scriptOrPath}
}
case shellutil.ShellPwsh, shellutil.ShellPowerShell:
if isInline {
cmdArgs = []string{shell, "-Command", e.buildPowerShellInlineCommand(scriptOrPath)}
cmdArgs = []string{shellBin, "-Command", e.buildPowerShellInlineCommand(scriptOrPath)}
skipAppendArgs = true
} else {
cmdArgs = []string{shell, "-File", scriptOrPath}
cmdArgs = []string{shellBin, "-File", scriptOrPath}
}
case shellutil.ShellCmd:
cmdArgs = []string{shell, "/c", scriptOrPath}
cmdArgs = []string{shellBin, "/c", scriptOrPath}
default:
// Unknown shell: use Unix-like -c pattern as fallback
// Unknown shell: use Unix-like -c pattern as fallback.
// Preserve original casing for custom interpreters.
if isInline {
cmdArgs = []string{shell, "-c", scriptOrPath}
} else {
Expand Down
27 changes: 21 additions & 6 deletions cli/src/internal/executor/command_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ func TestBuildCommandWithCustomShell(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exec := New(Config{Shell: tt.shell, Args: tt.args})
exec, err := New(Config{Args: tt.args})
if err != nil {
t.Fatalf("New() error: %v", err)
}
cmd := exec.buildCommand(tt.shell, tt.scriptPath, false)

// Check if command was built
Expand Down Expand Up @@ -75,7 +78,10 @@ func TestBuildCommandShellVariations(t *testing.T) {

for _, tt := range tests {
t.Run(tt.shell, func(t *testing.T) {
exec := New(Config{Shell: tt.shell})
exec, err := New(Config{Shell: tt.shell})
if err != nil {
t.Fatalf("New() error: %v", err)
}
cmd := exec.buildCommand(tt.shell, tt.scriptPath, false)

if cmd == nil {
Expand All @@ -87,7 +93,10 @@ func TestBuildCommandShellVariations(t *testing.T) {

func TestBuildCommandLookPath(t *testing.T) {
// Test that buildCommand creates a valid exec.Cmd
exec := New(Config{})
exec, err := New(Config{})
if err != nil {
t.Fatalf("New() error: %v", err)
}
cmd := exec.buildCommand("cmd", "test.bat", false)

// On Windows, cmd should be findable
Expand All @@ -96,7 +105,7 @@ func TestBuildCommandLookPath(t *testing.T) {
}

// Verify we can look up the command
_, err := execLookPath(cmd.Args[0])
_, err = execLookPath(cmd.Args[0])
if err != nil {
t.Logf("Command %v not found in PATH (may be platform-specific)", cmd.Args[0])
}
Expand Down Expand Up @@ -133,15 +142,21 @@ func TestQuotePowerShellArg(t *testing.T) {

func TestBuildPowerShellInlineCommand(t *testing.T) {
t.Run("no args returns script as-is", func(t *testing.T) {
e := New(Config{})
e, err := New(Config{})
if err != nil {
t.Fatalf("New() error: %v", err)
}
got := e.buildPowerShellInlineCommand("Get-Date")
if got != "Get-Date" {
t.Errorf("got %q, want %q", got, "Get-Date")
}
})

t.Run("with args joins and quotes", func(t *testing.T) {
e := New(Config{Args: []string{"arg1", "it's"}})
e, err := New(Config{Args: []string{"arg1", "it's"}})
if err != nil {
t.Fatalf("New() error: %v", err)
}
got := e.buildPowerShellInlineCommand("cmd")
want := "cmd 'arg1' 'it''s'"
if got != want {
Expand Down
Loading
Loading