Repository: lima-vm/lima

Issue:
Add a `--with-env` flag to `limactl shell`
Lima tries to create the illusion that containers are running locally by mounting directories and forwarding ports. But there is no mechanism to propagate environment variables to commands executed within the VM.

This is especially needed for `nerdctl` which cannot run on the host but must be executed inside the VM (see #412).

I propose to add a `--with-env` feature to `limactl shell` to pass along all environment variables explicitly on the commandline. Running `nerdctl -e FOO ...` will then become 

```shell
limactl shell default FOO=foo … nerdctl -e FOO ...
```

We will have an internal block list that will exclude a set of variables, like `PATH`, `SSH_*` etc.

The user can specify their own blocklist instead:

```
export LIMA_SHELLENV_BLOCK="PATH,SSH_*"
```

Exact syntax TBD, but I think it can be simply a list of names, with a `*` as a wildcard. I don't think we need full regex syntax, but could change my mind if somebody shows a real usecase that can't be done with a simpler form.

If the blocklist starts with a `+` it will be added to the builtin block list; otherwise it will replace it.

Alternatively the user can specify `LIMA_SHELLENV_ALLOW` which enumerates all allowed env variables instead (maybe no wildcards allowed?).

The builtin block list will be included in the `limactl info` output.

`nerdctl.lima` will become `limactl shell --with-env default nerdctl "$@"`.

Thoughts?

Hints from issue comments:
@AkihiroSuda: SGTM

Bikeshedding on the flag name: `--propagate-env`, `--inherit-env`, `--inherit`, ...@afbjorklund: The sudo command has `--preserve-env`, for what it is worth (with capital `-E`)

EDIT: Unintuitively, since the configuration is called `env_keep`, but whatever.@jandubois: > Bikeshedding on the flag name: `--propagate-env`, `--inherit-env`, `--inherit`, ...

I dislike `--propagate-env`. `--inherit-env` is fine (but `--with-env` is shorter). `--inherit` by itself is ambiguous to me.

> The sudo command has `--preserve-env`, for what it is worth

Yes, that works too. `sudo` also allows `--preserve-env=FOO,BAR` which we could support as well. It would be equivalent to `LIMA_SHELLENV_ALLOW=FOO,BAR limatctl shell …`.

I think if both `LIMA_SHELLENV_BLOCK` and `LIMA_SHELLENV_ALLOW` are set simultaneously, then the former should be ignored (with a warning).@jandubois: > Yes, that works too. `sudo` also allows `--preserve-env=FOO,BAR` which we could support as well. It would be equivalent to `LIMA_SHELLENV_ALLOW=FOO,BAR limatctl shell …`.

Actually, it would not really work well because we would need to treat `--preserve-env=FOO` differently from `--preserve-env FOO`. In the latter case `FOO` would be the instance name:

```shell
limactl shell --preserve-env FOO ls
```

This does not match how argument parsing works for any other option (except booleans).@AkihiroSuda: `--preserve-env` seems quite useful for `OPENAI_API_KEY`, `GEMINI_API_KEY`, and `ANTHROPIC_API_KEY`

Related proposal for running AI agents inside Lima:
- https://github.com/lima-vm/lima/issues/3711

@olamilekan000: I am currently working on this.

Solution patch:
diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go
index a49fe072..e48e2ae0 100644
--- a/cmd/limactl/shell.go
+++ b/cmd/limactl/shell.go
@@ -19,6 +19,7 @@ import (
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
+	"github.com/lima-vm/lima/v2/pkg/envutil"
 	"github.com/lima-vm/lima/v2/pkg/instance"
 	"github.com/lima-vm/lima/v2/pkg/ioutilx"
 	"github.com/lima-vm/lima/v2/pkg/limayaml"
@@ -54,6 +55,7 @@ func newShellCommand() *cobra.Command {
 	shellCmd.Flags().String("shell", "", "Shell interpreter, e.g. /bin/bash")
 	shellCmd.Flags().String("workdir", "", "Working directory")
 	shellCmd.Flags().Bool("reconnect", false, "Reconnect to the SSH session")
+	shellCmd.Flags().Bool("preserve-env", false, "Propagate environment variables to the shell")
 	return shellCmd
 }
 
@@ -178,7 +180,23 @@ func shellAction(cmd *cobra.Command, args []string) error {
 	} else {
 		shell = shellescape.Quote(shell)
 	}
-	script := fmt.Sprintf("%s ; exec %s --login", changeDirCmd, shell)
+	// Handle environment variable propagation
+	var envPrefix string
+	preserveEnv, err := cmd.Flags().GetBool("preserve-env")
+	if err != nil {
+		return err
+	}
+	if preserveEnv {
+		filteredEnv := envutil.FilterEnvironment()
+		if len(filteredEnv) > 0 {
+			envPrefix = "env "
+			for _, envVar := range filteredEnv {
+				envPrefix += shellescape.Quote(envVar) + " "
+			}
+		}
+	}
+
+	script := fmt.Sprintf("%s ; exec %s%s --login", changeDirCmd, envPrefix, shell)
 	if len(args) > 1 {
 		quotedArgs := make([]string, len(args[1:]))
 		parsingEnv := true
diff --git a/cmd/nerdctl.lima b/cmd/nerdctl.lima
index 230514b8..b398e075 100755
--- a/cmd/nerdctl.lima
+++ b/cmd/nerdctl.lima
@@ -1,3 +1,3 @@
 #!/bin/sh
 set -eu
-exec lima nerdctl "$@"
+exec limactl shell --preserve-env default nerdctl "$@"
diff --git a/go.mod b/go.mod
index 6b80bcfb..3c16007f 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/balajiv113/fd v0.0.0-20230330094840-143eec500f3e
 	github.com/cheggaaa/pb/v3 v3.1.7 // gomodjail:unconfined
 	github.com/containerd/continuity v0.4.5
-	github.com/containers/gvisor-tap-vsock v0.8.6 // gomodjail:unconfined
+	github.com/containers/gvisor-tap-vsock v0.8.7 // gomodjail:unconfined
 	github.com/coreos/go-semver v0.3.1
 	github.com/cpuguy83/go-md2man/v2 v2.0.7
 	github.com/digitalocean/go-qemu v0.0.0-20221209210016-f035778c97f7
@@ -116,7 +116,7 @@ require (
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yuin/gopher-lua v1.1.1 // indirect
 	golang.org/x/crypto v0.41.0 // indirect
-	golang.org/x/mod v0.26.0 // indirect
+	golang.org/x/mod v0.27.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/term v0.34.0 // indirect
 	golang.org/x/time v0.7.0 // indirect
diff --git a/go.sum b/go.sum
index bb4883c0..9e41e508 100644
--- a/go.sum
+++ b/go.sum
@@ -36,8 +36,8 @@ github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un
 github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
 github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/containers/gvisor-tap-vsock v0.8.6 h1:9SeAXK+K2o36CtrgYk6zRXbU3zrayjvkrI8b7/O6u5A=
-github.com/containers/gvisor-tap-vsock v0.8.6/go.mod h1:+0mtKmm4STeSDnZe+DGnIwN4EH2f7AcWir7PwT28Ti0=
+github.com/containers/gvisor-tap-vsock v0.8.7 h1:mFMMU5CIXO9sbtsgECc90loUHx15km3AN6Zuhg3X4qM=
+github.com/containers/gvisor-tap-vsock v0.8.7/go.mod h1:Rf2gm4Lpac0IZbg8wwQDh7UuKCxHmnxar0hEZ08OXY8=
 github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
 github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -203,8 +203,8 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
 github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
 github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
-github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
-github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
+github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
+github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
@@ -304,8 +304,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
-golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
+golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
+golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
diff --git a/pkg/envutil/envutil.go b/pkg/envutil/envutil.go
new file mode 100644
index 00000000..50625336
--- /dev/null
+++ b/pkg/envutil/envutil.go
@@ -0,0 +1,135 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package envutil
+
+import (
+	"os"
+	"slices"
+	"strings"
+
+	"github.com/sirupsen/logrus"
+)
+
+// defaultBlockList contains environment variables that should not be propagated by default.
+var defaultBlockList = []string{
+	"BASH*",
+	"DISPLAY",
+	"DYLD_*",
+	"EUID",
+	"FPATH",
+	"GID",
+	"GROUP",
+	"HOME",
+	"HOSTNAME",
+	"LD_*",
+	"LOGNAME",
+	"OLDPWD",
+	"PATH",
+	"PWD",
+	"SHELL",
+	"SHLVL",
+	"SSH_*",
+	"TERM",
+	"TERMINFO",
+	"TMPDIR",
+	"UID",
+	"USER",
+	"XAUTHORITY",
+	"XDG_*",
+	"ZDOTDIR",
+	"ZSH*",
+	"_*", // Variables starting with underscore are typically internal
+}
+
+func getBlockList() []string {
+	blockEnv := os.Getenv("LIMA_SHELLENV_BLOCK")
+	if blockEnv == "" {
+		return defaultBlockList
+	}
+	after, found := strings.CutPrefix(blockEnv, "+")
+	if !found {
+		return parseEnvList(blockEnv)
+	}
+	return slices.Concat(defaultBlockList, parseEnvList(after))
+}
+
+func getAllowList() []string {
+	if allowEnv := os.Getenv("LIMA_SHELLENV_ALLOW"); allowEnv != "" {
+		return parseEnvList(allowEnv)
+	}
+	return nil
+}
+
+func parseEnvList(envList string) []string {
+	parts := strings.Split(envList, ",")
+	result := make([]string, 0, len(parts))
+	for _, part := range parts {
+		if trimmed := strings.TrimSpace(part); trimmed != "" {
+			result = append(result, trimmed)
+		}
+	}
+
+	return result
+}
+
+func matchesPattern(name, pattern string) bool {
+	if pattern == name {
+		return true
+	}
+
+	prefix, found := strings.CutSuffix(pattern, "*")
+	return found && strings.HasPrefix(name, prefix)
+}
+
+func matchesAnyPattern(name string, patterns []string) bool {
+	return slices.ContainsFunc(patterns, func(pattern string) bool {
+		return matchesPattern(name, pattern)
+	})
+}
+
+// FilterEnvironment filters environment variables based on configuration from environment variables.
+// It returns a slice of environment variables that are not blocked by the current configuration.
+// The filtering is controlled by LIMA_SHELLENV_BLOCK and LIMA_SHELLENV_ALLOW environment variables.
+func FilterEnvironment() []string {
+	return filterEnvironmentWithLists(
+		os.Environ(),
+		getAllowList(),
+		getBlockList(),
+	)
+}
+
+func filterEnvironmentWithLists(env, allowList, blockList []string) []string {
+	var filtered []string
+
+	for _, envVar := range env {
+		parts := strings.SplitN(envVar, "=", 2)
+		if len(parts) != 2 {
+			continue
+		}
+
+		name := parts[0]
+
+		if len(allowList) > 0 {
+			if !matchesAnyPattern(name, allowList) {
+				continue
+			}
+			filtered = append(filtered, envVar)
+			continue
+		}
+
+		if matchesAnyPattern(name, blockList) {
+			logrus.Debugf("Blocked env variable %q", name)
+			continue
+		}
+
+		filtered = append(filtered, envVar)
+	}
+
+	return filtered
+}
+
+// GetDefaultBlockList returns a copy of the default block list.
+func GetDefaultBlockList() []string {
+	return slices.Clone(defaultBlockList)
+}
diff --git a/pkg/guestagent/procnettcp/procnettcp.go b/pkg/guestagent/procnettcp/procnettcp.go
index b86237a2..0d429f8f 100644
--- a/pkg/guestagent/procnettcp/procnettcp.go
+++ b/pkg/guestagent/procnettcp/procnettcp.go
@@ -41,11 +41,11 @@ type Entry struct {
 	State State  `json:"state"`
 }
 
-func Parse(r io.Reader, kind Kind) ([]Entry, error) {
-	return ParseWithEndian(r, kind, cpu.IsBigEndian)
+func parse(r io.Reader, kind Kind) ([]Entry, error) {
+	return parseWithEndian(r, kind, cpu.IsBigEndian)
 }
 
-func ParseWithEndian(r io.Reader, kind Kind, isBE bool) ([]Entry, error) {
+func parseWithEndian(r io.Reader, kind Kind, isBE bool) ([]Entry, error) {
 	switch kind {
 	case TCP, TCP6, UDP, UDP6:
 	default:
@@ -78,7 +78,7 @@ func ParseWithEndian(r io.Reader, kind Kind, isBE bool) ([]Entry, error) {
 		default:
 			// localAddress is like "0100007F:053A"
 			localAddress := fields[fieldNames["local_address"]]
-			ip, port, err := ParseAddressWithEndian(localAddress, isBE)
+			ip, port, err := parseAddressWithEndian(localAddress, isBE)
 			if err != nil {
 				return entries, err
 			}
@@ -105,7 +105,7 @@ func ParseWithEndian(r io.Reader, kind Kind, isBE bool) ([]Entry, error) {
 	return entries, nil
 }
 
-// ParseAddress parses a string.
+// parseAddressWithEndian parses a string.
 //
 // Little endian hosts:
 // "0100007F:0050"                         (127.0.0.1:80)
@@ -118,11 +118,7 @@ func ParseWithEndian(r io.Reader, kind Kind, isBE bool) ([]Entry, error) {
 // "00000000000000000000000000000000:0050" (0.0.0.0:80)
 //
 // See https://serverfault.com/questions/592574/why-does-proc-net-tcp6-represents-1-as-1000
-func ParseAddress(s string) (net.IP, uint16, error) {
-	return ParseAddressWithEndian(s, cpu.IsBigEndian)
-}
-
-func ParseAddressWithEndian(s string, isBE bool) (net.IP, uint16, error) {
+func parseAddressWithEndian(s string, isBE bool) (net.IP, uint16, error) {
 	split := strings.SplitN(s, ":", 2)
 	if len(split) != 2 {
 		return nil, 0, fmt.Errorf("unparsable address %q", s)
diff --git a/pkg/guestagent/procnettcp/procnettcp_linux.go b/pkg/guestagent/procnettcp/procnettcp_linux.go
index ae39224f..8810b692 100644
--- a/pkg/guestagent/procnettcp/procnettcp_linux.go
+++ b/pkg/guestagent/procnettcp/procnettcp_linux.go
@@ -25,7 +25,7 @@ func ParseFiles() ([]Entry, error) {
 			}
 			return res, err
 		}
-		parsed, err := Parse(r, kind)
+		parsed, err := parse(r, kind)
 		if err != nil {
 			_ = r.Close()
 			return res, err
diff --git a/pkg/limainfo/limainfo.go b/pkg/limainfo/limainfo.go
index 9f8fdfc9..19e5d7b0 100644
--- a/pkg/limainfo/limainfo.go
+++ b/pkg/limainfo/limainfo.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/sirupsen/logrus"
 
+	"github.com/lima-vm/lima/v2/pkg/envutil"
 	"github.com/lima-vm/lima/v2/pkg/limayaml"
 	"github.com/lima-vm/lima/v2/pkg/registry"
 	"github.com/lima-vm/lima/v2/pkg/store/dirnames"
@@ -25,6 +26,7 @@ type LimaInfo struct {
 	VMTypes         []string                     `json:"vmTypes"`     // since Lima v0.14.2
 	VMTypesEx       map[string]DriverExt         `json:"vmTypesEx"`   // since Lima v2.0.0
 	GuestAgents     map[limayaml.Arch]GuestAgent `json:"guestAgents"` // since Lima v1.1.0
+	ShellEnvBlock   []string                     `json:"shellEnvBlock"`
 }
 
 type DriverExt struct {
@@ -67,6 +69,7 @@ func New() (*LimaInfo, error) {
 		VMTypes:         vmTypes,
 		VMTypesEx:       vmTypesEx,
 		GuestAgents:     make(map[limayaml.Arch]GuestAgent),
+		ShellEnvBlock:   envutil.GetDefaultBlockList(),
 	}
 	info.Templates, err = templatestore.Templates()
 	if err != nil {



Test patch:
diff --git a/pkg/envutil/envutil_test.go b/pkg/envutil/envutil_test.go
new file mode 100644
index 00000000..a0ff3399
--- /dev/null
+++ b/pkg/envutil/envutil_test.go
@@ -0,0 +1,210 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package envutil
+
+import (
+	"os"
+	"slices"
+	"strings"
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+func isUsingDefaultBlockList() bool {
+	blockEnv := os.Getenv("LIMA_SHELLENV_BLOCK")
+	return blockEnv == "" || strings.HasPrefix(blockEnv, "+")
+}
+
+func TestMatchesPattern(t *testing.T) {
+	tests := []struct {
+		name     string
+		pattern  string
+		expected bool
+	}{
+		{"PATH", "PATH", true},
+		{"PATH", "HOME", false},
+		{"SSH_AUTH_SOCK", "SSH_*", true},
+		{"SSH_AGENT_PID", "SSH_*", true},
+		{"HOME", "SSH_*", false},
+		{"XDG_CONFIG_HOME", "XDG_*", true},
+		{"_LIMA_TEST", "_*", true},
+		{"LIMA_HOME", "_*", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name+"_matches_"+tt.pattern, func(t *testing.T) {
+			result := matchesPattern(tt.name, tt.pattern)
+			assert.Equal(t, result, tt.expected)
+		})
+	}
+}
+
+func TestMatchesAnyPattern(t *testing.T) {
+	patterns := []string{"PATH", "SSH_*", "XDG_*"}
+
+	tests := []struct {
+		name     string
+		expected bool
+	}{
+		{"PATH", true},
+		{"HOME", false},
+		{"SSH_AUTH_SOCK", true},
+		{"XDG_CONFIG_HOME", true},
+		{"USER", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := matchesAnyPattern(tt.name, patterns)
+			assert.Equal(t, result, tt.expected)
+		})
+	}
+}
+
+func TestParseEnvList(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected []string
+	}{
+		{"", []string{}},
+		{"PATH", []string{"PATH"}},
+		{"PATH,HOME", []string{"PATH", "HOME"}},
+		{"PATH, HOME , USER", []string{"PATH", "HOME", "USER"}},
+		{" , , ", []string{}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			result := parseEnvList(tt.input)
+			assert.DeepEqual(t, result, tt.expected)
+		})
+	}
+}
+
+func TestGetBlockAndAllowLists(t *testing.T) {
+	t.Run("default config", func(t *testing.T) {
+		t.Setenv("LIMA_SHELLENV_BLOCK", "")
+		t.Setenv("LIMA_SHELLENV_ALLOW", "")
+
+		blockList := getBlockList()
+		allowList := getAllowList()
+
+		assert.Assert(t, isUsingDefaultBlockList())
+		assert.DeepEqual(t, blockList, defaultBlockList)
+		assert.Equal(t, len(allowList), 0)
+	})
+
+	t.Run("custom blocklist", func(t *testing.T) {
+		t.Setenv("LIMA_SHELLENV_BLOCK", "PATH,HOME")
+
+		blockList := getBlockList()
+		assert.Assert(t, !isUsingDefaultBlockList())
+		expected := []string{"PATH", "HOME"}
+		assert.DeepEqual(t, blockList, expected)
+	})
+
+	t.Run("additive blocklist", func(t *testing.T) {
+		t.Setenv("LIMA_SHELLENV_BLOCK", "+CUSTOM_VAR")
+
+		blockList := getBlockList()
+		assert.Assert(t, isUsingDefaultBlockList())
+		expected := slices.Concat(GetDefaultBlockList(), []string{"CUSTOM_VAR"})
+		assert.DeepEqual(t, blockList, expected)
+	})
+
+	t.Run("allowlist", func(t *testing.T) {
+		t.Setenv("LIMA_SHELLENV_ALLOW", "FOO,BAR")
+
+		allowList := getAllowList()
+		expected := []string{"FOO", "BAR"}
+		assert.DeepEqual(t, allowList, expected)
+	})
+}
+
+func TestFilterEnvironment(t *testing.T) {
+	testEnv := []string{
+		"PATH=/usr/bin",
+		"HOME=/home/user",
+		"USER=testuser",
+		"FOO=bar",
+		"SSH_AUTH_SOCK=/tmp/ssh",
+		"XDG_CONFIG_HOME=/config",
+		"BASH_VERSION=5.0",
+		"_INTERNAL=secret",
+		"CUSTOM_VAR=value",
+	}
+
+	t.Run("default blocklist", func(t *testing.T) {
+		result := filterEnvironmentWithLists(testEnv, nil, defaultBlockList)
+
+		expected := []string{"FOO=bar", "CUSTOM_VAR=value"}
+		assert.Assert(t, containsAll(result, expected))
+
+		blockedPrefixes := []string{
+			"PATH=",
+			"HOME=",
+			"SSH_AUTH_SOCK=",
+			"XDG_CONFIG_HOME=",
+			"BASH_VERSION=",
+			"_INTERNAL=",
+		}
+		for _, prefix := range blockedPrefixes {
+			for _, envVar := range result {
+				assert.Assert(t, !strings.HasPrefix(envVar, prefix), "Expected result to not contain variable with prefix %q, but found %q", prefix, envVar)
+			}
+		}
+	})
+
+	t.Run("custom blocklist", func(t *testing.T) {
+		result := filterEnvironmentWithLists(testEnv, nil, []string{"FOO"})
+
+		assert.Assert(t, !slices.Contains(result, "FOO=bar"))
+
+		expected := []string{"PATH=/usr/bin", "HOME=/home/user", "USER=testuser"}
+		assert.Assert(t, containsAll(result, expected))
+	})
+
+	t.Run("allowlist", func(t *testing.T) {
+		result := filterEnvironmentWithLists(testEnv, []string{"FOO", "USER"}, nil)
+
+		expected := []string{"FOO=bar", "USER=testuser"}
+		assert.Equal(t, len(result), len(expected))
+		assert.Assert(t, containsAll(result, expected))
+	})
+
+	t.Run("allowlist takes precedence over blocklist", func(t *testing.T) {
+		result := filterEnvironmentWithLists(testEnv, []string{"FOO", "CUSTOM_VAR"}, []string{"FOO", "USER"})
+
+		expected := []string{"FOO=bar", "CUSTOM_VAR=value"}
+		assert.Assert(t, containsAll(result, expected))
+
+		assert.Assert(t, !slices.Contains(result, "USER=testuser"))
+	})
+}
+
+func containsAll(slice, items []string) bool {
+	for _, item := range items {
+		if !slices.Contains(slice, item) {
+			return false
+		}
+	}
+	return true
+}
+
+func TestGetDefaultBlockList(t *testing.T) {
+	blocklist := GetDefaultBlockList()
+
+	if &blocklist[0] == &defaultBlockList[0] {
+		t.Error("GetDefaultBlockList should return a copy, not the original slice")
+	}
+
+	assert.DeepEqual(t, blocklist, defaultBlockList)
+
+	expectedItems := []string{"PATH", "HOME", "SSH_*"}
+	for _, item := range expectedItems {
+		found := slices.Contains(blocklist, item)
+		assert.Assert(t, found, "Expected builtin blocklist to contain %q", item)
+	}
+}
diff --git a/pkg/guestagent/procnettcp/fuzz_test.go b/pkg/guestagent/procnettcp/fuzz_test.go
index 9d3fa7b3..79e94b51 100644
--- a/pkg/guestagent/procnettcp/fuzz_test.go
+++ b/pkg/guestagent/procnettcp/fuzz_test.go
@@ -14,6 +14,6 @@ func FuzzParse(f *testing.F) {
 		if tcp6 {
 			kind = TCP6
 		}
-		_, _ = Parse(bytes.NewReader(data), kind)
+		_, _ = parse(bytes.NewReader(data), kind)
 	})
 }
diff --git a/pkg/guestagent/procnettcp/procnettcp_test.go b/pkg/guestagent/procnettcp/procnettcp_test.go
index cd9c2b9d..d53d9f97 100644
--- a/pkg/guestagent/procnettcp/procnettcp_test.go
+++ b/pkg/guestagent/procnettcp/procnettcp_test.go
@@ -21,7 +21,7 @@ func TestParseTCP(t *testing.T) {
    4: 0100007F:053A 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 31430 1 0000000000000000 100 0 0 10 0                     
    5: 0B3CA8C0:0016 690AA8C0:F705 01 00000000:00000000 02:00028D8B 00000000     0        0 32989 4 0000000000000000 20 4 31 10 19
 `
-	entries, err := ParseWithEndian(strings.NewReader(procNetTCP), TCP, isBE)
+	entries, err := parseWithEndian(strings.NewReader(procNetTCP), TCP, isBE)
 	assert.NilError(t, err)
 	t.Log(entries)
 
@@ -38,7 +38,7 @@ func TestParseTCP6(t *testing.T) {
 	const isBE = false
 	procNetTCP := `  sl  local_address                         remote_address                        st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
 	   0: 000080FE00000000FF57A6705DC771FE:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 850222 1 0000000000000000 100 0 0 10 0`
-	entries, err := ParseWithEndian(strings.NewReader(procNetTCP), TCP6, isBE)
+	entries, err := parseWithEndian(strings.NewReader(procNetTCP), TCP6, isBE)
 	assert.NilError(t, err)
 	t.Log(entries)
 
@@ -54,7 +54,7 @@ func TestParseTCP6Zero(t *testing.T) {
    1: 00000000000000000000000000000000:006F 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 26772 1 0000000000000000 100 0 0 10 0
    2: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 1210901 1 0000000000000000 100 0 0 10 0
 `
-	entries, err := ParseWithEndian(strings.NewReader(procNetTCP), TCP6, isBE)
+	entries, err := parseWithEndian(strings.NewReader(procNetTCP), TCP6, isBE)
 	assert.NilError(t, err)
 	t.Log(entries)
 
@@ -71,7 +71,7 @@ func TestParseUDP(t *testing.T) {
   731: 0369A8C0:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000   998        0 29132 2 0000000000000000 0         
   731: 0F05A8C0:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000   998        0 4049 2 0000000000000000 0          
  1768: 00000000:1451 00000000:0000 07 00000000:00000000 00:00000000 00000000   502        0 28364 2 0000000000000000 0  `
-	entries, err := ParseWithEndian(strings.NewReader(procNetTCP), UDP, isBE)
+	entries, err := parseWithEndian(strings.NewReader(procNetTCP), UDP, isBE)
 	assert.NilError(t, err)
 	t.Log(entries)
 
@@ -149,7 +149,7 @@ func TestParseAddress(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.input, func(t *testing.T) {
-			ip, port, err := ParseAddressWithEndian(test.input, test.bigEndian)
+			ip, port, err := parseAddressWithEndian(test.input, test.bigEndian)
 			if test.expectedErrSubstr != "" {
 				assert.ErrorContains(t, err, test.expectedErrSubstr)
 			} else {
diff --git a/pkg/limayaml/limayaml_test.go b/pkg/limayaml/limayaml_test.go
index 39c929ea..2fbeebf0 100644
--- a/pkg/limayaml/limayaml_test.go
+++ b/pkg/limayaml/limayaml_test.go
@@ -4,8 +4,11 @@
 package limayaml
 
 import (
+	"bytes"
 	"encoding/json"
 	"os"
+	"path/filepath"
+	"runtime"
 	"testing"
 
 	"gotest.tools/v3/assert"
@@ -30,10 +33,18 @@ func TestEmptyYAML(t *testing.T) {
 const defaultYAML = "{}\n"
 
 func TestDefaultYAML(t *testing.T) {
-	bytes, err := os.ReadFile("default.yaml")
+	content, err := os.ReadFile("default.yaml")
 	assert.NilError(t, err)
+	// if this is the unresolved symlink as a file, then make sure to resolve it
+	if runtime.GOOS == "windows" && bytes.HasPrefix(content, []byte{'.', '.'}) {
+		f, err := filepath.Rel(".", string(content))
+		assert.NilError(t, err)
+		content, err = os.ReadFile(f)
+		assert.NilError(t, err)
+	}
+
 	var y LimaYAML
-	err = Unmarshal(bytes, &y, "")
+	err = Unmarshal(content, &y, "")
 	assert.NilError(t, err)
 	y.Images = nil                // remove default images
 	y.Mounts = nil                // remove default mounts