Skip to content

Fix env var leak when running extension commands with -e flag#7035

Merged
jongio merged 13 commits intomainfrom
copilot/fix-env-variable-leak
Mar 19, 2026
Merged

Fix env var leak when running extension commands with -e flag#7035
jongio merged 13 commits intomainfrom
copilot/fix-env-variable-leak

Conversation

Copy link
Contributor

Copilot AI commented Mar 6, 2026

Extension commands use DisableFlagParsing: true, so cobra never parses -e/--environment. This caused lazyEnv to always resolve to the default environment, leaking its variables into the extension process even when a different environment was explicitly requested. AZD_ENVIRONMENT was also never set in these cases.

Root cause

Two call sites both relied on cmd.Flags().GetString("environment") which returns "" for any command with DisableFlagParsing: true:

  • The EnvFlag DI resolver in container.go — controls which environment lazyEnv loads
  • extensionAction.Run() in extensions.go — controls InvokeOptions.Environment (i.e., AZD_ENVIRONMENT propagation)

Changes

Follows the established GlobalCommandOptions pattern used by --cwd, --debug, and --no-prompt:

  • internal/global_command_options.go — Added EnvironmentName field to GlobalCommandOptions
  • cmd/auto_install.goParseGlobalFlags() now pre-parses -e/--environment from raw args before cobra runs, storing the value in GlobalCommandOptions.EnvironmentName. The flag is added only to the parsing flag set (not to CreateGlobalFlagSet()) so it doesn't appear in every command's help text. Includes a Lookup guard to prevent panic if a future refactor adds environment to CreateGlobalFlagSet(). Invalid environment names are validated early using environment.IsValidEnvironmentName().
  • cmd/container.goEnvFlag DI resolver now accepts globalOptions *internal.GlobalCommandOptions with a three-level fallback chain: Cobra flag → context value (envFlagCtxKey, used by workflow composition like azd up) → globalOptions.EnvironmentName. A debug log line fires when the globalOptions fallback is used.
  • cmd/extensions.goextensionAction.Run() now uses a.globalOptions directly for all InvokeOptions fields (Debug, Cwd, Environment, NoPrompt), removing the fragile cmd.Flags().GetString() calls that silently fail for extension commands
  • internal/env_flag.go — Unexported envNameEnvVarName since it's only used within the internal package, avoiding confusion with environment.EnvNameEnvVarName
  • TestsTestParseGlobalFlags_EnvironmentName validates early parsing (short flags, long flags, equals syntax, concatenated -edev, AZURE_ENV_NAME fallback/override, multiple -e last-wins, mixed with unknown flags, cross-field assertions, -e without value error). TestParseGlobalFlags_InvalidEnvironmentName validates rejection of invalid names. TestRunnerInvoke_GlobalFlagPropagation verifies end-to-end propagation of InvokeOptions fields to AZD_* environment variables in the extension child process.
Original prompt

This section details on the original issue you should resolve

<issue_title>azd -e leaks default environment variables into extension process</issue_title>
<issue_description>## Description

When running an extension command with -e <env> to specify a non-default environment, environment variables from the default environment leak into the extension process. Variables that exist only in the default environment (and are not overridden by the -e environment) are visible to the extension and its child processes.

Steps to Reproduce

  1. Create a project with two environments:

    azd env new dev
    azd env new staging
    azd env select staging   # make staging the default
    
  2. Set a variable ONLY in staging:

    azd env set MY_STAGING_VAR "staging-value" -e staging
    
  3. Set a different variable in dev:

    azd env set MY_DEV_VAR "dev-value" -e dev
    
  4. Run an extension with -e dev:

    azd app run -e dev
    
  5. In the child process, BOTH MY_STAGING_VAR and MY_DEV_VAR are present in the environment.

Expected Behavior

Only variables from the -e dev environment should be present. MY_STAGING_VAR (which exists only in the staging/default environment) should NOT leak into the extension process.

Actual Behavior

The extension process receives a merged set of environment variables: default environment values as the base, with -e environment values overlaid on top. Any variable that exists ONLY in the default environment leaks through because the -e environment doesn't define it to override.

Root Cause Analysis

Traced through the source code:

cli/azd/cmd/extensions.goextensionAction.Run():

allEnv := []string{}
allEnv = append(allEnv, os.Environ()...)          // system env

env, err := a.lazyEnv.GetValue()                   // resolves to... which env?
if err == nil && env != nil {
    allEnv = append(allEnv, env.Environ()...)      // injects env values
}

The problem is that lazyEnv resolves to the default environment rather than the -e environment. This is because extension commands are registered with DisableFlagParsing: true:

cmd := &cobra.Command{
    Use:                lastPart,
    DisableFlagParsing: true,  // prevents -e from being parsed by cobra
}

With DisableFlagParsing: true, cobra does not parse persistent flags (including -e) for extension commands. The flag value is passed through to the extension as args, but a.cmd.Flags().GetString("environment") returns "". This causes the DI-injected lazyEnv to fall back to the default environment.

Additionally, the envName extraction logic also hits this wall:

envName, _ := a.cmd.Flags().GetString("environment")  // returns "" due to DisableFlagParsing
options := &extensions.InvokeOptions{
    Environment: envName,  // "" → AZD_ENVIRONMENT not set
}

Since envName is "", AZD_ENVIRONMENT is not propagated to the extension process either (the runner.go guard checks if options.Environment != "").

Impact

This is a data isolation bug. Environment-specific secrets, API endpoints, feature flags, and configuration from the default environment leak into non-default environment runs. This can cause:

  • Wrong API endpoints being used
  • Feature flags from one environment activating in another
  • Staging-specific behavior appearing in dev (or vice versa)
  • Secrets from one environment being visible in another

Suggested Fix

Ensure that when -e <env> is specified, lazyEnv resolves to that environment, not the default. Options:

  1. Parse -e before dispatching to extensions: Extract the -e/--environment flag value from args before DisableFlagParsing takes effect, and use it for DI environment resolution.
  2. Use the middleware path: The middleware-based extension execution path (cli/azd/cmd/middleware/extensions.go) may handle flag parsing differently via m.options.Flags.GetString("environment"). Ensure parity between the two execution paths.
  3. Don't inject lazyEnv.Environ() for extension commands: Let extensions query the gRPC EnvironmentService for values explicitly, rather than inheriting them via process environment.

Workaround

Run azd env select <env> before running the extension command to make the desired environment the default:

azd env select dev
azd app run    # now uses dev as default, no -e needed

Environment

  • azd version: 1.23.7
  • OS: Windows 11
  • Extension: jongio/azd-app v0.13.2 (but affects all extensions that rely on inherited env vars)</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI changed the title [WIP] Fix environment variable leak into extension process Fix env var leak when running extension commands with -e flag Mar 6, 2026
@jongio
Copy link
Member

jongio commented Mar 7, 2026

@copilot that looks like a lazy implementation, fix it with better design principles

Copy link
Contributor Author

Copilot AI commented Mar 7, 2026

@copilot that looks like a lazy implementation, fix it with better design principles

Redesigned in 79d1f16. Instead of scattered parseEnvFlagFromArgs + DisableFlagParsing checks, this now follows the established GlobalCommandOptions pattern used by --cwd, --debug, and --no-prompt:

  1. Added EnvironmentName to GlobalCommandOptions
  2. ParseGlobalFlags() pre-parses -e/--environment from raw args before cobra runs — single source of truth
  3. container.go EnvFlag resolver falls back to globalOptions.EnvironmentName
  4. extensions.go uses a.globalOptions directly for all InvokeOptions fields

No more DisableFlagParsing checks, no standalone helper functions, no extra DI parameters.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • aka.ms
    • Triggering command: /home/REDACTED/work/azure-dev/azure-dev/cli/azd/azd /home/REDACTED/work/azure-dev/azure-dev/cli/azd/azd extension source add -n local -t file -l /home/REDACTED/work/azure-dev/azure-dev/cli/azd/extensions/registry.json -o x_amd64/vet -p st/mocks/mockexe-errorsas olang.org/grpc@v-ifaceassert x_amd64/vet storage/items.go/opt/hostedtoolcache/go/1.26.1/x64/pkg/tool/linux_amd64/vet ce/internal/env -I x_amd64/vet (dns block)
    • Triggering command: /home/REDACTED/work/azure-dev/azure-dev/cli/azd/azd /home/REDACTED/work/azure-dev/azure-dev/cli/azd/azd extension list --source local --output json x_amd64/vet --gdwarf-5 --64 -o x_amd64/vet -o st/mocks/mockhtt-errorsas olang.org/protob-ifaceassert x_amd64/vet -p noop -lang=go1.24 x_amd64/vet (dns block)
    • Triggering command: /home/REDACTED/work/azure-dev/azure-dev/cli/azd/azd /home/REDACTED/work/azure-dev/azure-dev/cli/azd/azd extension install azure.ai.agents --source local --version 0.1.13-preview --gdwarf-5 --64 -o x_amd64/vet -o 3@v3.2.0/recorde-errorsas olang.org/grpc@v-ifaceassert x_amd64/vet base.go /v1.39.0/otelcon-atomic -lang=go1.26 x_amd64/vet (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@microsoft-github-policy-service microsoft-github-policy-service bot added no-recent-activity identity issues with no activity and removed no-recent-activity identity issues with no activity labels Mar 14, 2026
@jongio jongio force-pushed the copilot/fix-env-variable-leak branch from 999ac6d to b0cd863 Compare March 16, 2026 16:15
@jongio
Copy link
Member

jongio commented Mar 16, 2026

@copilot please re-review the latest changes.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Contributor

@wbreza wbreza left a comment

Choose a reason for hiding this comment

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

Code Review Summary

What Looks Good

  • Follows established patterns: The fix mirrors how --cwd, --debug, and --no-prompt are handled via GlobalCommandOptions
  • Clean separation of concerns: ParseGlobalFlags pre-parses, DI resolver falls back, extensionAction reads from single source
  • Comprehensive unit tests for parsing: 11 table-driven test cases
  • Good comments: Every change is well-commented explaining why, not just what
  • No concurrency or performance concerns: Write-once/read-many pattern is safe

Findings Summary

Priority Count
Critical 0
High 0
Medium 4
Low 3
Total 7

Overall Assessment: Comment. Solid fix that follows established patterns.


@jongio This is a well-structured fix and the ParseGlobalFlags tests are great. It would be worth adding some additional integration-style tests to validate the end-to-end behavior for workflow-based commands like azd up -e env, particularly around how the environment propagates through the DI resolver and into extension InvokeOptions.

Copilot AI and others added 13 commits March 18, 2026 16:24
Co-authored-by: jongio <2163001+jongio@users.noreply.github.com>
Instead of scattered parseEnvFlagFromArgs + DisableFlagParsing checks,
follow the established pattern for --cwd/--debug/--no-prompt:

1. Add EnvironmentName to GlobalCommandOptions
2. Parse -e/--environment in ParseGlobalFlags() before cobra runs
3. Use globalOptions.EnvironmentName as fallback in EnvFlag DI resolver
4. Use a.globalOptions directly in extensionAction.Run() for InvokeOptions

Co-authored-by: jongio <2163001+jongio@users.noreply.github.com>
- ParseGlobalFlags now uses os.Getenv("AZURE_ENV_NAME") as default for -e flag,
  matching EnvFlag.Bind() behavior so CI pipelines setting AZURE_ENV_NAME work
  for extension commands
- Export EnvNameEnvVarName constant for cross-package reuse
- Add test cases for env var fallback, flag override, concatenated -e, multi -e
- Use exported constant in tests instead of hardcoded string
- Remove non-idiomatic "Warning:" prefix from error message

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tions

Address review feedback from @wbreza and @vhvb1989:
> 'The globalOptions fallback is placed before the context check (envFlagCtxKey).
> The context mechanism exists for workflow composition. azd up uses
> context.WithValue to propagate environment to sub-commands. Consider swapping
> the order so context takes precedence over globalOptions.'

Swapped the fallback chain in the EnvFlag DI resolver so the ordering is now:
  Cobra flag -> context value -> globalOptions
This preserves the design intent where workflow composition (azd up)
takes precedence over the pre-parsed CLI flag.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses @wbreza's review comment on extensions.go:258:
> "The InvokeOptions construction and mapping is an important
> part of the contract between azd and extensions. I'd like to
> see test coverage for this."

Adds TestRunnerInvoke_GlobalFlagPropagation with 3 cases:
- all global flags set (Debug, NoPrompt, Cwd, Environment)
- only environment set (verifies others absent)
- no global flags (verifies no AZD_* env vars)

Uses a mock CommandRunner to capture RunArgs and verify
that InvokeOptions fields are correctly propagated as
AZD_DEBUG, AZD_NO_PROMPT, AZD_CWD, AZD_ENVIRONMENT.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses @wbreza's review comment on auto_install.go:678:
> "[Medium] Input Validation: Consider validating environment
> name early. The raw -e value is stored without validation.
> For extension commands, extensionAction.Run() passes it
> directly to runner.Invoke() as AZD_ENVIRONMENT in the child
> process."

Uses environment.IsValidEnvironmentName() to reject invalid
names before they reach GlobalCommandOptions. Adds test
TestParseGlobalFlags_InvalidEnvironmentName with cases for
spaces and special characters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses @wbreza's review comment on auto_install_test.go:497:
> "[Medium] A few edge cases and cross-field assertions would
> strengthen this. 1. -e without a value to verify the
> error/behavior when the flag is at end of args. 2. Cross-field
> assertions for mixed-flag cases — assert EnableDebugLogging
> and NoPrompt are true alongside -e."

Adds:
- Cross-field assertion: verifies --debug and --no-prompt are
  correctly parsed alongside -e dev
- Edge case: -e at end of args with no value returns an error

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses @wbreza's review comment on env_flag.go:23:
> "[Low] Two exported constants with the same name and value in
> different packages (internal.EnvNameEnvVarName and
> environment.EnvNameEnvVarName) may confuse callers. Consider
> keeping it unexported if only ParseGlobalFlags (in cmd) needs
> it."

Renames internal.EnvNameEnvVarName to unexported envNameEnvVarName
since it's only used within the internal package (EnvFlag.Bind).
Updates cmd/ references to use environment.EnvNameEnvVarName instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses @wbreza's review comment on auto_install.go:644:
> "[Low] pflag.StringP panics on duplicate names. A
> globalFlagSet.Lookup guard would protect against a future
> refactor that adds environment to CreateGlobalFlagSet()."

Wraps the StringP call in a Lookup nil-check so a future
refactor that moves the environment flag into CreateGlobalFlagSet
won't cause a panic from duplicate registration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses @wbreza's review comment on container.go:204:
> "[Low] The log says 'using default environment instead' but
> with this fallback, the resolver may actually use the
> pre-parsed -e value or AZURE_ENV_NAME. Consider adding a brief
> log line when this fallback fires to aid debugging."

Adds a log.Printf when the DI resolver falls back to
globalOptions.EnvironmentName, making it clear in debug logs
that the pre-parsed value is being used instead of the default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The rebase dropped the internal package import needed for
GlobalCommandOptions in TestNewRootCmd.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jongio jongio force-pushed the copilot/fix-env-variable-leak branch from 108fe90 to a2e26fe Compare March 18, 2026 23:28
@jongio jongio merged commit 5fa9424 into main Mar 19, 2026
8 checks passed
@jongio jongio deleted the copilot/fix-env-variable-leak branch March 19, 2026 00:10
Copy link
Contributor

@wbreza wbreza left a comment

Choose a reason for hiding this comment

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

Code Review Summary

Prior Review Resolution Status

All 8 prior review threads from @wbreza and @vhvb1989 have been fully resolved:

# Finding Status Verification
1 Fallback ordering: Cobra → context → globalOptions ✅ Fixed container.go confirms correct precedence chain
2 InvokeOptions from globalOptions untested ✅ Fixed runner_test.go with 3 test cases verifying AZD_* propagation
3 Validate environment name early ✅ Fixed IsValidEnvironmentName() check in ParseGlobalFlags
4 Edge cases: -e without value, cross-field ✅ Fixed Both tests present in auto_install_test.go
5 Unexport envNameEnvVarName ✅ Fixed env_flag.go uses lowercase envNameEnvVarName
6 Lookup guard before StringP ✅ Fixed globalFlagSet.Lookup() == nil guard present
7 Debug log when fallback fires ✅ Fixed log.Printf when globalOptions fallback is used
8 vhvb1989: Swap fallback ordering ✅ Fixed Context value checked before globalOptions

Findings Summary

Priority Count
Critical 0
High 0
Medium 1
Low 0
Total 1

🟡 Medium (1 finding)

DI resolver fallback chain lacks direct unit test (container.go lines 192-230)

The 3-level fallback chain (Cobra flag → context value → globalOptions) in the EnvFlag DI resolver has no direct unit test. runner_test.go tests the downstream InvokeOptions → AZD_* mapping but doesn't exercise the resolver itself. @wbreza raised this in the original review and accepted runner_test.go as partial coverage — testing DI resolvers directly requires significant IoC infrastructure. Known accepted gap, not a blocker.

✅ What Looks Good

  • Follows established patterns: Mirrors exactly how --cwd, --debug, and --no-prompt are handled via GlobalCommandOptions
  • Clean separation of concerns: ParseGlobalFlags pre-parses once → DI resolver falls back → extensionAction reads from single source
  • Comprehensive tests: 11 table-driven parsing tests + cross-field assertions + edge cases + 3 runner propagation tests + invalid name tests
  • Well-documented: Every change has comments explaining why, not just what
  • All CI checks passing: golangci-lint (Linux + Windows), go-fix, copyright, cspell, bicep-lint, CLA — all green
  • Input validation: Environment names validated early with IsValidEnvironmentName(), preventing injection

Overall Assessment: Approve. Well-structured fix that correctly addresses a real data isolation bug following established patterns.

globalOptions.EnvironmentName,
)
envValue = globalOptions.EnvironmentName
}
Copy link
Contributor

Choose a reason for hiding this comment

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

[Medium] DI resolver fallback chain lacks direct unit test

The 3-level fallback chain (Cobra flag → context value → globalOptions) has no direct unit test. runner_test.go tests the downstream InvokeOptions → AZD_* mapping but doesn't exercise this resolver itself — e.g., verifying that a context value overrides globalOptions when both are set.

@wbreza raised this in the original review and accepted runner_test.go as partial coverage. Testing DI resolvers directly requires significant IoC infrastructure. Known accepted gap, not a blocker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

azd <extension> -e <env> leaks default environment variables into extension process

6 participants