forked from Azure/azure-dev
-
Notifications
You must be signed in to change notification settings - Fork 0
[Codex] azure/azure-dev#7400 — feat: add azd exec — run commands and scripts with azd environment context #1
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
Open
IanMatthewHuff
wants to merge
13
commits into
base/pr-7400-4275158-run-20260522T205054Z
Choose a base branch
from
review/pr-7400-f39c4e9-run-20260522T205054Z-codex
base: base/pr-7400-4275158-run-20260522T205054Z
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
506a18c
feat: add azd exec as a core command
jongio b2c9cb2
test/docs: add exec action tests and update environment docs
jongio 0fc6e79
fix: replace os.Exit in exec action with ExitCodeError for proper mid…
jongio 86777fe
security: scope child env, harden cmd.exe quoting, guard exit code
jongio 194557f
security: prevent inline fallback for file paths, add process tree cl…
jongio 0ae1059
fix: resolve CI lint failures (gofmt, gosec) and update snapshots
jongio a984611
fix: add remaining gosec nolint directives for test credentials
jongio 5f3e2f1
fix: move gosec nolint directive to variable declaration line
jongio 42feaeb
fix: add diagnostic logging to Job Object fallback paths in proctree_…
jongio bae81fc
fix: address review feedback - escape cmd.exe %VAR%, gate stdin on in…
jongio c170a7d
fix: add gosec nolint comment for test build command
jongio 1a16e4a
fix: move nolint directive above line to satisfy lll lint rule
jongio fc93afe
fix: update exec usage snapshot after rebase onto main
jongio 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
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 |
|---|---|---|
|
|
@@ -280,6 +280,7 @@ stdlib | |
| stdouttrace | ||
| STRINGSLICE | ||
| struct | ||
| stretchr | ||
| structs | ||
| subst | ||
| substr | ||
|
|
||
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,204 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
|
|
||
| "github.com/azure/azure-dev/cli/azd/cmd/actions" | ||
| "github.com/azure/azure-dev/cli/azd/internal" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/environment" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/exec/scripting" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/keyvault" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/output" | ||
| "github.com/spf13/cobra" | ||
| "github.com/spf13/pflag" | ||
| ) | ||
|
|
||
| func execActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { | ||
| root.Add("exec", &actions.ActionDescriptorOptions{ | ||
| Command: newExecCmd(), | ||
| FlagsResolver: newExecFlags, | ||
| ActionResolver: newExecAction, | ||
| OutputFormats: []output.Format{output.NoneFormat}, | ||
| DefaultFormat: output.NoneFormat, | ||
| GroupingOptions: actions.CommandGroupOptions{ | ||
| RootLevelHelp: actions.CmdGroupManage, | ||
| }, | ||
| }) | ||
| return root | ||
| } | ||
|
|
||
| func newExecCmd() *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "exec [command] [args...] [-- script-args...]", | ||
| Short: "Execute commands and scripts with azd environment context.", | ||
| Long: `Execute commands and scripts with full access to azd environment variables. | ||
|
|
||
| Commands are run with the azd environment loaded into the child process. | ||
| Multiple arguments use direct process execution (no shell wrapping). | ||
| A single quoted argument uses shell inline execution. | ||
|
|
||
| Examples: | ||
| azd exec python script.py # Direct exec (exact argv) | ||
| azd exec npm run dev # Direct exec (no shell) | ||
| azd exec -- python app.py --port 8000 # Direct exec with flags | ||
| azd exec 'echo $AZURE_ENV_NAME' # Inline via shell | ||
| azd exec ./setup.sh # Execute script file | ||
| azd exec --shell pwsh "Write-Host 'Hello'" # Inline PowerShell | ||
| azd exec ./build.sh -- --verbose # Script with args | ||
| azd exec -i ./init.sh # Interactive mode`, | ||
| Args: cobra.MinimumNArgs(1), | ||
| } | ||
| // Stop cobra from parsing flags after the first positional argument | ||
| // so that `azd exec python --version` passes --version to python. | ||
| cmd.Flags().SetInterspersed(false) | ||
| cmd.FParseErrWhitelist.UnknownFlags = true | ||
| return cmd | ||
| } | ||
|
|
||
| type execFlags struct { | ||
| internal.EnvFlag | ||
| global *internal.GlobalCommandOptions | ||
| shell string | ||
| interactive bool | ||
| } | ||
|
|
||
| func newExecFlags( | ||
| cmd *cobra.Command, global *internal.GlobalCommandOptions, | ||
| ) *execFlags { | ||
| flags := &execFlags{} | ||
| flags.Bind(cmd.Flags(), global) | ||
| return flags | ||
| } | ||
|
|
||
| func (f *execFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { | ||
| f.EnvFlag.Bind(local, global) | ||
| f.global = global | ||
|
|
||
| local.StringVarP(&f.shell, "shell", "s", "", | ||
| "Shell to use (bash, sh, zsh, pwsh, powershell, cmd). "+ | ||
| "Auto-detected if not specified.") | ||
| local.BoolVarP(&f.interactive, "interactive", "i", false, | ||
| "Run in interactive mode (connect stdin)") | ||
| } | ||
|
|
||
| type execAction struct { | ||
| env *environment.Environment | ||
| keyvaultService keyvault.KeyVaultService | ||
| flags *execFlags | ||
| args []string | ||
| } | ||
|
|
||
| func newExecAction( | ||
| env *environment.Environment, | ||
| keyvaultService keyvault.KeyVaultService, | ||
| flags *execFlags, | ||
| args []string, | ||
| ) actions.Action { | ||
| return &execAction{ | ||
| env: env, | ||
| keyvaultService: keyvaultService, | ||
| flags: flags, | ||
| args: args, | ||
| } | ||
| } | ||
|
|
||
| // buildChildEnv creates a scoped environment for the child process. | ||
| // It starts from the current process env, then overlays azd environment | ||
| // variables. Key Vault references (akvs:// and @Microsoft.KeyVault(SecretUri=...)) | ||
| // are resolved transparently. We never call os.Setenv — secrets stay out of | ||
| // the parent process. | ||
| func (a *execAction) buildChildEnv(ctx context.Context) ([]string, error) { | ||
| childEnv := os.Environ() | ||
| subscriptionId := a.env.GetSubscriptionId() | ||
| for key, value := range a.env.Dotenv() { | ||
| resolved := value | ||
| if keyvault.IsSecretReference(value) { | ||
| secret, err := a.keyvaultService.SecretFromKeyVaultReference(ctx, value, subscriptionId) | ||
| if err != nil { | ||
| return nil, fmt.Errorf( | ||
| "resolving secret for %q: %w", key, err) | ||
| } | ||
| resolved = secret | ||
| } | ||
| childEnv = append(childEnv, key+"="+resolved) | ||
| } | ||
| return childEnv, nil | ||
| } | ||
|
|
||
| func (a *execAction) Run(ctx context.Context) (*actions.ActionResult, error) { | ||
| childEnv, err := a.buildChildEnv(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| scriptInput := a.args[0] | ||
| var scriptArgs []string | ||
| if len(a.args) > 1 { | ||
| scriptArgs = a.args[1:] | ||
| } | ||
|
|
||
| exec, err := scripting.New(scripting.Config{ | ||
| Shell: a.flags.shell, | ||
| Interactive: a.flags.interactive, | ||
| Args: scriptArgs, | ||
| Env: childEnv, | ||
| }) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("invalid configuration: %w", err) | ||
| } | ||
|
|
||
| // Try file execution first; fall back based on argument shape. | ||
| if err := exec.Execute(ctx, scriptInput); err != nil { | ||
| if _, ok := errors.AsType[*scripting.ScriptNotFoundError](err); ok { | ||
| // Guard: if the input looks like a file path (has path separators | ||
| // or a known script extension), don't fall through to inline/direct | ||
| // execution — the user intended to run a file that doesn't exist. | ||
| if looksLikeFilePath(scriptInput) { | ||
| return nil, err | ||
| } | ||
| if len(scriptArgs) > 0 && a.flags.shell == "" { | ||
| err = exec.ExecuteDirect(ctx, scriptInput, scriptArgs) | ||
| } else { | ||
| err = exec.ExecuteInline(ctx, scriptInput) | ||
| } | ||
| } | ||
| if err != nil { | ||
| if execErr, ok := errors.AsType[*scripting.ExecutionError](err); ok { | ||
| return nil, &internal.ExitCodeError{ | ||
| ExitCode: execErr.ExitCode, | ||
| Err: err, | ||
| } | ||
| } | ||
| return nil, err | ||
| } | ||
| } | ||
|
|
||
| return nil, nil | ||
| } | ||
|
|
||
| // scriptExtensions lists file extensions that indicate a script file, | ||
| // used by looksLikeFilePath to prevent unsafe inline fallback. | ||
| var scriptExtensions = map[string]bool{ | ||
| ".sh": true, ".bash": true, ".zsh": true, | ||
| ".ps1": true, ".cmd": true, ".bat": true, | ||
| ".py": true, ".rb": true, ".pl": true, | ||
| } | ||
|
|
||
| // looksLikeFilePath reports whether input appears to be a file path rather | ||
| // than a bare command or inline script. Used to prevent falling through to | ||
| // inline execution when a user typos a script name (F15 security fix). | ||
| func looksLikeFilePath(input string) bool { | ||
| if strings.ContainsAny(input, "/\\") { | ||
| return true | ||
| } | ||
| ext := strings.ToLower(filepath.Ext(input)) | ||
| return ext != "" && scriptExtensions[ext] | ||
| } | ||
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.
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.
Runalways callsExecutefirst, andExecutetreats any existing filesystem entry as a script candidate. That breaks valid direct-command scenarios: if the current directory contains a folder named like the command (for examplego),azd exec go versionreturns a directory validation error instead of executinggofromPATH; and if the first arg is an executable file path (./tool --help), it is run via a shell (bash ./tool/powershell -File) rather than direct exec, which fails for non-script binaries. This regression comes from probing as a script before applying the direct-exec heuristic.Useful? React with 👍 / 👎.