Skip to content
Draft
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
22 changes: 22 additions & 0 deletions pkg/provider/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ func combineOptions(
return options
}

// ToEnvironment builds the full environment for provider subprocess execution.
// It combines the current process environment (os.Environ) with provider-specific
// variables from three sources:
//
// - ToOptions: merged provider config options (uppercased key=value pairs),
// plus workspace identity vars (WORKSPACE_ID, WORKSPACE_PROVIDER, etc.),
// machine identity vars (MACHINE_ID, MACHINE_PROVIDER, etc.),
// and base vars (PROVIDER_ID, PROVIDER_CONTEXT, PROVIDER_FOLDER, DEVPOD, DEVPOD_OS, DEVPOD_ARCH).
// - extraEnv: caller-supplied additional variables.
//
// The provider name is available to subprocesses as PROVIDER_ID (set by GetBaseEnvironment),
// WORKSPACE_PROVIDER (set by ToOptionsWorkspace), and MACHINE_PROVIDER (set by ToOptionsMachine).
//
// Note: DEVPOD_PROVIDER is reserved by the --provider CLI flag via inheritFlagsFromEnvironment
// in cmd/root.go. It is inherited from the parent process via os.Environ() but must not be
// explicitly set here, as doing so would override the global provider selection for child
// devpod processes.
Comment on lines +38 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Documented DEVPOD_PROVIDER restriction is not enforced for extraEnv.

The comment says DEVPOD_PROVIDER must not be explicitly set, but Line 55-Line 57 blindly appends all extraEnv entries, including DEVPOD_PROVIDER.

Suggested patch
 	for k, v := range extraEnv {
+		if k == "DEVPOD_PROVIDER" {
+			continue
+		}
 		osEnviron = append(osEnviron, k+"="+v)
 	}

Also applies to: 55-57

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

In `@pkg/provider/env.go` around lines 38 - 41, The code currently appends all
entries from extraEnv including DEVPOD_PROVIDER which violates the documented
restriction; update the logic that iterates over extraEnv (the block that
appends extraEnv entries in pkg/provider/env.go) to skip any entry whose key
equals "DEVPOD_PROVIDER" (case-sensitive) so it is never added to the resulting
environment for child devpod processes; include a short comment above the
filtering to reference the DEVPOD_PROVIDER restriction for future readers.

func ToEnvironment(
workspace *Workspace,
machine *Machine,
Expand Down Expand Up @@ -143,6 +160,11 @@ func Merge(m1 map[string]string, m2 map[string]string) map[string]string {
return retMap
}

// GetBaseEnvironment returns system-level env vars set for every provider subprocess:
// DEVPOD (binary path), DEVPOD_OS, DEVPOD_ARCH, PROVIDER_ID (provider name),
// PROVIDER_CONTEXT, PROVIDER_FOLDER, and DEVPOD_LOG_LEVEL.
//
// PROVIDER_ID is the canonical way for provider scripts to discover their own name.
func GetBaseEnvironment(context, provider string) map[string]string {
retVars := map[string]string{}

Expand Down
67 changes: 67 additions & 0 deletions pkg/provider/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package provider

import (
"slices"
"strings"
"testing"
)

func TestToEnvironment_ContainsProviderID(t *testing.T) {
ws := &Workspace{
ID: "test-workspace",
Context: "default",
Provider: WorkspaceProviderConfig{
Name: "test-provider",
},
Source: WorkspaceSource{},
}

environ := ToEnvironment(ws, nil, nil, nil)

assertEnvContains(t, environ, "PROVIDER_ID", "test-provider")
assertEnvContains(t, environ, "WORKSPACE_PROVIDER", "test-provider")
assertEnvContains(t, environ, "WORKSPACE_ID", "test-workspace")
}

func TestToEnvironment_DoesNotDuplicateDevpodProvider(t *testing.T) {
ws := &Workspace{
ID: "test-workspace",
Context: "default",
Provider: WorkspaceProviderConfig{
Name: "test-provider",
},
Source: WorkspaceSource{},
}

environ := ToEnvironment(ws, nil, nil, nil)

// DEVPOD_PROVIDER is reserved by the --provider CLI flag.
// It may appear from os.Environ() but must not be explicitly added.
count := 0
for _, entry := range environ {
if strings.HasPrefix(entry, "DEVPOD_PROVIDER=") {
count++
}
}
if count > 1 {
t.Errorf(
"found %d DEVPOD_PROVIDER entries; expected at most 1 (from os.Environ)",
count,
)
}
}

func TestToEnvironment_IncludesExtraEnv(t *testing.T) {
extra := map[string]string{"CUSTOM_VAR": "custom_value"}
environ := ToEnvironment(nil, nil, nil, extra)

assertEnvContains(t, environ, "CUSTOM_VAR", "custom_value")
}

func assertEnvContains(t *testing.T, environ []string, key, value string) {
t.Helper()
expected := key + "=" + value
if !slices.Contains(environ, expected) {
t.Errorf("expected %s in environment, not found", expected)
}
}
31 changes: 31 additions & 0 deletions pkg/provider/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ var ProviderNameRegEx = regexp.MustCompile(`[^a-z0-9\-]+`)

var optionNameRegEx = regexp.MustCompile(`[^A-Z0-9_]+`)

// reservedOptionNames are env var names set by the DevPod runtime
// (via GetBaseEnvironment, ToOptionsWorkspace, ToOptionsMachine).
// Provider options must not use these names as they would silently
// overwrite system-set values in the subprocess environment.
var reservedOptionNames = map[string]bool{
"PROVIDER_ID": true,
"PROVIDER_CONTEXT": true,
"PROVIDER_FOLDER": true,
"WORKSPACE_ID": true,
"WORKSPACE_UID": true,
"WORKSPACE_PROVIDER": true,
"WORKSPACE_CONTEXT": true,
"WORKSPACE_FOLDER": true,
"WORKSPACE_SOURCE": true,
"WORKSPACE_ORIGIN": true,
"WORKSPACE_PICTURE": true,
"MACHINE_ID": true,
"MACHINE_CONTEXT": true,
"MACHINE_FOLDER": true,
"MACHINE_PROVIDER": true,
"LOFT_PROJECT": true,
}
Comment on lines +25 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reserved-name validation is incomplete and misses contract-critical env vars.

reservedOptionNames currently omits DEVPOD_PROVIDER (explicitly reserved in this PR’s contract) and several system vars set by GetBaseEnvironment (DEVPOD, DEVPOD_OS, DEVPOD_ARCH, DEVPOD_LOG_LEVEL). Those names can still be used as provider options and bypass the new protection.

Suggested patch
 var reservedOptionNames = map[string]bool{
+	"DEVPOD":            true,
+	"DEVPOD_OS":         true,
+	"DEVPOD_ARCH":       true,
+	"DEVPOD_LOG_LEVEL":  true,
+	"DEVPOD_PROVIDER":   true,
 	"PROVIDER_ID":        true,
 	"PROVIDER_CONTEXT":   true,
 	"PROVIDER_FOLDER":    true,
 	"WORKSPACE_ID":       true,
 	"WORKSPACE_UID":      true,
 	"WORKSPACE_PROVIDER": true,
 	"WORKSPACE_CONTEXT":  true,
 	"WORKSPACE_FOLDER":   true,
 	"WORKSPACE_SOURCE":   true,
 	"WORKSPACE_ORIGIN":   true,
 	"WORKSPACE_PICTURE":  true,
 	"MACHINE_ID":         true,
 	"MACHINE_CONTEXT":    true,
 	"MACHINE_FOLDER":     true,
 	"MACHINE_PROVIDER":   true,
 	"LOFT_PROJECT":       true,
 }

Also applies to: 101-107

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

In `@pkg/provider/parse.go` around lines 25 - 42, The reservedOptionNames map is
missing several contract-critical env var names, allowing them to be used as
provider options; update the reservedOptionNames declaration to include
"DEVPOD_PROVIDER", "DEVPOD", "DEVPOD_OS", "DEVPOD_ARCH", and "DEVPOD_LOG_LEVEL"
(the same missing keys must also be added where the map is duplicated around the
other occurrence referenced in the comment). Ensure these names match the keys
produced by GetBaseEnvironment so provider options cannot override those system
variables.


var allowedTypes = []string{
"string",
"multiline",
Expand Down Expand Up @@ -75,6 +98,14 @@ func validate(config *ProviderConfig) error {
)
}

if reservedOptionNames[optionName] {
return fmt.Errorf(
"provider option '%s' uses a reserved environment variable name; "+
"choose a different name to avoid overwriting system-set values",
optionName,
)
}

// validate option validation
if optionValue.ValidationPattern != "" {
_, err := regexp.Compile(optionValue.ValidationPattern)
Expand Down
58 changes: 58 additions & 0 deletions pkg/provider/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package provider

import (
"testing"

"github.com/skevetter/devpod/pkg/types"
)

func TestValidate_RejectsReservedOptionNames(t *testing.T) {
reserved := []string{
"PROVIDER_ID",
"PROVIDER_CONTEXT",
"PROVIDER_FOLDER",
"WORKSPACE_ID",
"WORKSPACE_UID",
"WORKSPACE_PROVIDER",
"WORKSPACE_CONTEXT",
"WORKSPACE_FOLDER",
"WORKSPACE_SOURCE",
"WORKSPACE_ORIGIN",
"WORKSPACE_PICTURE",
"MACHINE_ID",
"MACHINE_CONTEXT",
"MACHINE_FOLDER",
"MACHINE_PROVIDER",
"LOFT_PROJECT",
}

for _, name := range reserved {
cfg := &ProviderConfig{
Name: "test-provider",
Options: map[string]*types.Option{
name: {Description: "test"},
},
}
err := validate(cfg)
if err == nil {
t.Errorf("expected error for reserved option name %q, got nil", name)
}
Comment on lines +29 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reserved-name test can pass for unrelated validation errors.

TestValidate_RejectsReservedOptionNames does not set Exec.Command, so validate(cfg) can fail with exec.command is required even if reserved-name validation is broken. This makes the test a false positive risk.

Suggested patch
 import (
+	"strings"
 	"testing"

 	"github.com/skevetter/devpod/pkg/types"
 )
@@
 	for _, name := range reserved {
 		cfg := &ProviderConfig{
 			Name: "test-provider",
 			Options: map[string]*types.Option{
 				name: {Description: "test"},
 			},
+			Exec: ProviderCommands{
+				Command: []string{"echo hello"},
+			},
 		}
 		err := validate(cfg)
-		if err == nil {
-			t.Errorf("expected error for reserved option name %q, got nil", name)
+		if err == nil || !strings.Contains(err.Error(), "reserved environment variable name") {
+			t.Errorf("expected reserved-name error for option %q, got: %v", name, err)
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/provider/parse_test.go` around lines 29 - 39, The test loop in
TestValidate_RejectsReservedOptionNames builds a ProviderConfig without required
Exec.Command, so validate(cfg) may fail on exec.command instead of reserved-name
checks; update the cfg in the loop to include a valid Exec with Command set
(e.g., cfg.Exec = &types.Exec{Command: "true"}) so validation reaches
reserved-option-name logic when calling validate(cfg) for each reserved name.

}
}

func TestValidate_AllowsNonReservedOptionNames(t *testing.T) {
cfg := &ProviderConfig{
Name: "test-provider",
Options: map[string]*types.Option{
"MY_CUSTOM_OPTION": {Description: "test"},
"AWS_REGION": {Description: "test"},
},
Exec: ProviderCommands{
Command: []string{"echo hello"},
},
}
err := validate(cfg)
if err != nil {
t.Errorf("expected no error for non-reserved names, got: %v", err)
}
}
Loading