Repository: lima-vm/lima

Issue:
External driver doesn’t exit after client disconnect
### Description

The external driver(server) process keeps running even after the limactl(client) exits, even though a keep‑alive timeout is implemented.
https://github.com/lima-vm/lima/blob/96c717915715200c371343dd5bb49ad61e0abb31/pkg/driver/external/server/server.go#L65-L73

`StopAllExternalDrivers()` in main.go normally stops these processes, and the keep‑alive in the `server.go` should auto‑exit the driver after ~20s of inactivity, but manual testing shows it never terminates, suggesting the keep‑alive isn’t triggering.

EDIT: The keep-alive timeouts don't shut down the server automatically. We need our own logic to handle this.

Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index acc37bbc..f1bd6438 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -36,8 +36,6 @@ jobs:
         # macOS SDK version
         xcrun --show-sdk-version || true
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 1
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 170dd0da..f77ab10e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -27,9 +27,6 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -83,8 +80,6 @@ jobs:
         git config --global core.autocrlf false
         git config --global core.eol lf
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 1
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -112,8 +107,6 @@ jobs:
     timeout-minutes: 5
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 1
     - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630  # v2.1
       with:
         check_filenames: true
@@ -135,8 +128,6 @@ jobs:
         sudo apt-get update
         sudo apt-get install -y --no-install-recommends qemu-utils
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 1
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: ${{ matrix.go-version }}
@@ -188,8 +179,6 @@ jobs:
         git config --global core.autocrlf false
         git config --global core.eol lf
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 1
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -218,9 +207,6 @@ jobs:
         git config --global core.autocrlf false
         git config --global core.eol lf
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "can't parse builtin Lima version" errors
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -246,9 +232,6 @@ jobs:
     timeout-minutes: 120
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -318,9 +301,6 @@ jobs:
         - ../hack/test-templates/test-misc.yaml  # TODO: merge net-user-v2 into test-misc
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -370,6 +350,8 @@ jobs:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
       with:
         # fetch-depth is set to 0 to let `limactl --version` print semver-ish version
+        # fetch-depth: 0 is required for Colima integration test because Colima
+        # checks Lima's version and requires proper semantic version format
         fetch-depth: 0
         ref: ${{ github.event.pull_request.head.sha }}
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
@@ -415,8 +397,6 @@ jobs:
     timeout-minutes: 120
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -470,9 +450,6 @@ jobs:
         oldver: ["v0.15.1"]  # The default VM type was always QEMU until Lima v1.0
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
     - name: Fetch homebrew-core commit messages
       uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
       with:
@@ -515,8 +492,6 @@ jobs:
         - default.yaml
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -550,8 +525,6 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -577,9 +550,6 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
@@ -592,9 +562,6 @@ jobs:
     timeout-minutes: 30
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
-        fetch-depth: 0
     - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5  # v5.5.0
       with:
         go-version: 1.24.x
diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go
index 4b3d7c1d..eba0042e 100644
--- a/cmd/limactl/editflags/editflags.go
+++ b/cmd/limactl/editflags/editflags.go
@@ -62,6 +62,12 @@ func RegisterEdit(cmd *cobra.Command, commentPrefix string) {
 
 	flags.String("set", "", commentPrefix+"Modify the template inplace, using yq syntax")
 
+	flags.Uint16("ssh-port", 0, commentPrefix+"SSH port (0 for random)") // colima-compatible
+	_ = cmd.RegisterFlagCompletionFunc("ssh-port", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
+		// Until Lima v2.0, 60022 was the default SSH port for the instance named "default".
+		return []string{"60022"}, cobra.ShellCompDirectiveNoFileComp
+	})
+
 	// negative performance impact: https://gitlab.com/qemu-project/qemu/-/issues/334
 	flags.Bool("video", false, commentPrefix+"Enable video output (has negative performance impact for QEMU)")
 
@@ -230,6 +236,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 			false,
 			false,
 		},
+		{"ssh-port", d(".ssh.localPort = %s"), false, false},
 		{"arch", d(".arch = %q"), true, false},
 		{
 			"containerd",
diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go
index 6ee01a03..77ac6d43 100644
--- a/cmd/limactl/main.go
+++ b/cmd/limactl/main.go
@@ -4,9 +4,11 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"runtime"
 	"strings"
@@ -40,7 +42,8 @@ func main() {
 			}
 		}
 	}
-	if err := newApp().Execute(); err != nil {
+	rootCmd := newApp()
+	if err := executeWithPluginSupport(rootCmd, os.Args[1:]); err != nil {
 		handleExitCoder(err)
 		logrus.Fatal(err)
 	}
@@ -131,12 +134,6 @@ func newApp() *cobra.Command {
 		if err != nil {
 			return err
 		}
-		// Make sure that directory is on a local filesystem, not on NFS
-		// if the directory does not yet exist, check the home directory
-		_, err = os.Stat(dir)
-		if errors.Is(err, os.ErrNotExist) {
-			dir = filepath.Dir(dir)
-		}
 		nfs, err := fsutil.IsNFS(dir)
 		if err != nil {
 			return err
@@ -215,6 +212,67 @@ func handleExitCoder(err error) {
 	}
 }
 
+// executeWithPluginSupport handles command execution with plugin support.
+func executeWithPluginSupport(rootCmd *cobra.Command, args []string) error {
+	if len(args) > 0 {
+		cmd, _, err := rootCmd.Find(args)
+		if err != nil || cmd == rootCmd {
+			// Function calls os.Exit() if it found and executed the plugin
+			runExternalPlugin(rootCmd.Context(), args[0], args[1:])
+		}
+	}
+
+	rootCmd.SetArgs(args)
+	return rootCmd.Execute()
+}
+
+func runExternalPlugin(ctx context.Context, name string, args []string) {
+	if ctx == nil {
+		ctx = context.Background()
+	}
+
+	if err := updatePathEnv(); err != nil {
+		logrus.Warnf("failed to update PATH environment: %v", err)
+		// PATH update failure shouldn't prevent plugin execution
+	}
+
+	externalCmd := "limactl-" + name
+	execPath, err := exec.LookPath(externalCmd)
+	if err != nil {
+		return
+	}
+
+	logrus.Debugf("found external command: %s", execPath)
+
+	cmd := exec.CommandContext(ctx, execPath, args...)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	cmd.Env = os.Environ()
+
+	handleExitCoder(cmd.Run())
+	os.Exit(0) //nolint:revive // it's intentional to call os.Exit in this function
+}
+
+func updatePathEnv() error {
+	exe, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("failed to get executable path: %w", err)
+	}
+
+	binDir := filepath.Dir(exe)
+	currentPath := os.Getenv("PATH")
+	newPath := binDir + string(filepath.ListSeparator) + currentPath
+
+	if err := os.Setenv("PATH", newPath); err != nil {
+		return fmt.Errorf("failed to set PATH environment: %w", err)
+	}
+
+	logrus.Debugf("updated PATH to prioritize %s", binDir)
+
+	return nil
+}
+
 // WrapArgsError annotates cobra args error with some context, so the error message is more user-friendly.
 func WrapArgsError(argFn cobra.PositionalArgs) cobra.PositionalArgs {
 	return func(cmd *cobra.Command, args []string) error {
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/pkg/driver/external/server/server.go b/pkg/driver/external/server/server.go
index b0c4140a..95d9f774 100644
--- a/pkg/driver/external/server/server.go
+++ b/pkg/driver/external/server/server.go
@@ -64,7 +64,7 @@ func Serve(driver driver.Driver) {
 
 	kaProps := keepalive.ServerParameters{
 		Time:    10 * time.Second,
-		Timeout: 20 * time.Second,
+		Timeout: 30 * time.Second,
 	}
 
 	kaPolicy := keepalive.EnforcementPolicy{
@@ -85,17 +85,41 @@ func Serve(driver driver.Driver) {
 	sigs := make(chan os.Signal, 1)
 	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
 
+	shutdownCh := make(chan struct{})
+
 	go func() {
 		<-sigs
 		logger.Info("Received shutdown signal, stopping server...")
-		server.GracefulStop()
+		close(shutdownCh)
 	}()
 
-	logger.Infof("Starting external driver server for %s", driver.Info().DriverName)
-	logger.Infof("Server starting on Unix socket: %s", socketPath)
-	if err := server.Serve(listener); err != nil {
-		logger.Fatalf("Failed to serve: %v", err)
-	}
+	go func() {
+		timer := time.NewTimer(60 * time.Second)
+		defer timer.Stop()
+
+		select {
+		case <-timer.C:
+			logger.Info("No client connected within 60 seconds, shutting down server...")
+			close(shutdownCh)
+		case <-shutdownCh:
+			return
+		}
+	}()
+
+	go func() {
+		logger.Infof("Starting external driver server for %s", driver.Info().DriverName)
+		logger.Infof("Server starting on Unix socket: %s", socketPath)
+		if err := server.Serve(listener); err != nil {
+			if errors.Is(err, grpc.ErrServerStopped) {
+				logger.Errorf("Server stopped: %v", err)
+			} else {
+				logger.Errorf("Failed to serve: %v", err)
+			}
+		}
+	}()
+
+	<-shutdownCh
+	server.GracefulStop()
 }
 
 func Start(extDriver *registry.ExternalDriver, instName string) error {
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/fsutil/fsutil_linux.go b/pkg/fsutil/fsutil_linux.go
index cd87951e..77aab23d 100644
--- a/pkg/fsutil/fsutil_linux.go
+++ b/pkg/fsutil/fsutil_linux.go
@@ -4,10 +4,28 @@
 package fsutil
 
 import (
+	"errors"
+	"os"
+	"path/filepath"
+
 	"golang.org/x/sys/unix"
 )
 
+// IsNFS checks if the path is on NFS. If the path does not exist yet, it will walk
+// up parent directories until one exists, or it hits '/' or '.'.
+// Any other stat errors will cause IsNFS to fail.
 func IsNFS(path string) (bool, error) {
+	for len(path) > 1 {
+		_, err := os.Stat(path)
+		if err == nil {
+			break
+		}
+		if !errors.Is(err, os.ErrNotExist) {
+			return false, err
+		}
+		path = filepath.Dir(path)
+	}
+
 	var sf unix.Statfs_t
 	if err := unix.Statfs(path, &sf); err != nil {
 		return false, 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 {
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index e09f56b3..10157b4c 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -40,12 +40,10 @@ func Validate(y *LimaYAML, warn bool) error {
 		if _, err := versionutil.Parse(*y.MinimumLimaVersion); err != nil {
 			errs = errors.Join(errs, fmt.Errorf("field `minimumLimaVersion` must be a semvar value, got %q: %w", *y.MinimumLimaVersion, err))
 		}
-		limaVersion, err := versionutil.Parse(version.Version)
-		if err != nil {
-			errs = errors.Join(errs, fmt.Errorf("can't parse builtin Lima version %q: %w", version.Version, err))
-		}
-		if limaVersion != nil && versionutil.GreaterThan(*y.MinimumLimaVersion, limaVersion.String()) {
-			errs = errors.Join(errs, fmt.Errorf("template requires Lima version %q; this is only %q", *y.MinimumLimaVersion, limaVersion.String()))
+		// Unparsable version.Version (like commit hashes or "<unknown>") is treated as "latest/greatest"
+		// and will pass all version comparisons, allowing development builds to work.
+		if !versionutil.GreaterEqual(version.Version, *y.MinimumLimaVersion) {
+			errs = errors.Join(errs, fmt.Errorf("template requires Lima version %q; this is only %q", *y.MinimumLimaVersion, version.Version))
 		}
 	}
 	if y.VMOpts.QEMU.MinimumVersion != nil {
diff --git a/pkg/version/versionutil/versionutil.go b/pkg/version/versionutil/versionutil.go
index 36efceba..f470ef77 100644
--- a/pkg/version/versionutil/versionutil.go
+++ b/pkg/version/versionutil/versionutil.go
@@ -31,7 +31,12 @@ func compare(limaVersion, oldVersion string) int {
 	if err != nil {
 		return 1
 	}
-	cmp := version.Compare(*semver.New(oldVersion))
+	// Handle Unparsable oldVersion gracefully - treat as 0.0.0 so Unparsable limaVersion is always greater
+	oldVer, err := semver.NewVersion(oldVersion)
+	if err != nil {
+		return 1
+	}
+	cmp := version.Compare(*oldVer)
 	if cmp == 0 && strings.Contains(limaVersion, "-") {
 		cmp = 1
 	}
diff --git a/website/content/en/docs/community/contributing.md b/website/content/en/docs/community/contributing.md
index 641c597a..c7b9aa32 100644
--- a/website/content/en/docs/community/contributing.md
+++ b/website/content/en/docs/community/contributing.md
@@ -3,7 +3,17 @@ title: Contributing
 weight: 20
 ---
 
-## Developer Certificate of Origin
+## Reporting issues
+
+Bugs and feature requests can be submitted via <https://github.com/lima-vm/lima/issues>.
+
+For asking questions, use [GitHub Discussions](https://github.com/lima-vm/lima/discussions) or [Slack (`#lima`)](https://slack.cncf.io).
+
+For reporting vulnerabilities, see <https://github.com/lima-vm/.github/blob/main/SECURITY.md>.
+
+## Contributing code
+
+### Developer Certificate of Origin
 
 Every commit must be signed off with the `Signed-off-by: REAL NAME <email@example.com>` line.
 
@@ -11,13 +21,13 @@ Use the `git commit -s` command to add the Signed-off-by line.
 
 See also <https://github.com/cncf/foundation/blob/main/policies-guidance/dco-guidelines.md>.
 
-## Licensing
+### Licensing
 
 Lima is licensed under the terms of [Apache License, Version 2.0](https://github.com/lima-vm/lima/blob/master/LICENSE).
 
 See also <https://github.com/cncf/foundation/blob/main/policies-guidance/allowed-third-party-license-policy.md> for third-party dependencies.
 
-## Sending pull requests
+### Sending pull requests
 
 Pull requests can be submitted to <https://github.com/lima-vm/lima/pulls>.
 
@@ -25,7 +35,7 @@ It is highly suggested to add [tests](../../dev/testing/) for every non-trivial
 A test can be implemented as a unit test rather than an integration test when it is possible,
 to avoid slowing the integration test CI.
 
-## Merging pull requests
+### Merging pull requests
 
 [Committers](../governance) can merge pull requests.
 [Reviewers](../governance) can approve, but cannot merge, pull requests.
diff --git a/website/content/en/docs/usage/_index.md b/website/content/en/docs/usage/_index.md
index 7a77bb56..74ae09a8 100644
--- a/website/content/en/docs/usage/_index.md
+++ b/website/content/en/docs/usage/_index.md
@@ -72,6 +72,61 @@ Then you can connect directly:
 ssh lima-default
 ```
 
+### Command Aliasing (Plugin System)
+
+Lima supports a plugin-like command aliasing system similar to `git`, `kubectl`, and `docker`. When you run a `limactl` command that doesn't exist, Lima will automatically look for an external program named `limactl-<command>` in your system's PATH.
+
+#### Creating Custom Aliases
+
+To create a custom alias, create an executable script with the name `limactl-<alias>` and place it somewhere in your PATH.
+
+**Example: Creating a `ps` alias for listing instances**
+
+1. Create a script called `limactl-ps`:
+   ```bash
+   #!/bin/sh
+   # Show instances in a compact format
+   limactl list --format table "$@"
+   ```
+
+2. Make it executable and place it in your PATH:
+   ```bash
+   chmod +x limactl-ps
+   sudo mv limactl-ps /usr/local/bin/
+   ```
+
+3. Now you can use it:
+   ```bash
+   limactl ps                    # Shows instances in table format
+   limactl ps --quiet            # Shows only instance names
+   ```
+
+**Example: Creating an `sh` alias**
+
+```bash
+#!/bin/sh
+# limactl-sh - Connect to an instance shell
+limactl shell "$@"
+```
+
+After creating this alias:
+```bash
+limactl sh default           # Equivalent to: limactl shell default
+limactl sh myinstance bash   # Equivalent to: limactl shell myinstance bash
+```
+
+#### How It Works
+
+1. When you run `limactl <unknown-command>`, Lima first tries to find a built-in command
+2. If no built-in command is found, Lima searches for `limactl-<unknown-command>` in your PATH
+3. If found, Lima executes the external program and passes all remaining arguments to it
+4. If not found, Lima shows the standard "unknown command" error
+
+This system allows you to:
+- Create personal shortcuts and aliases
+- Extend Lima's functionality without modifying the core application
+- Share custom commands with your team by distributing scripts
+
 ### Shell completion
 - To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`.
 - To enable zsh completion, see `limactl completion zsh --help`



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/limayaml/validate_test.go b/pkg/limayaml/validate_test.go
index 5c9d696a..390b2ec8 100644
--- a/pkg/limayaml/validate_test.go
+++ b/pkg/limayaml/validate_test.go
@@ -38,19 +38,19 @@ func TestValidateMinimumLimaVersion(t *testing.T) {
 			name:               "minimumLimaVersion greater than current version",
 			currentVersion:     "1.1.1-114-g5bf5e513",
 			minimumLimaVersion: "1.1.2",
-			wantErr:            `template requires Lima version "1.1.2"; this is only "1.1.1"`,
+			wantErr:            `template requires Lima version "1.1.2"; this is only "1.1.1-114-g5bf5e513"`,
 		},
 		{
 			name:               "invalid current version",
 			currentVersion:     "<unknown>",
 			minimumLimaVersion: "0.8.0",
-			wantErr:            `can't parse builtin Lima version "<unknown>": <unknown> is not in dotted-tri format`,
+			wantErr:            "", // Unparsable versions are treated as "latest"
 		},
 		{
 			name:               "invalid minimumLimaVersion",
 			currentVersion:     "1.1.1-114-g5bf5e513",
 			minimumLimaVersion: "invalid",
-			wantErr:            "field `minimumLimaVersion` must be a semvar value, got \"invalid\": invalid is not in dotted-tri format\ntemplate requires Lima version \"invalid\"; this is only \"1.1.1\"",
+			wantErr:            "field `minimumLimaVersion` must be a semvar value, got \"invalid\": invalid is not in dotted-tri format", // Only parse error, no comparison error
 		},
 	}
 
diff --git a/pkg/version/versionutil/versionutil_test.go b/pkg/version/versionutil/versionutil_test.go
index 620efc82..89a9776c 100644
--- a/pkg/version/versionutil/versionutil_test.go
+++ b/pkg/version/versionutil/versionutil_test.go
@@ -41,3 +41,19 @@ func TestParse(t *testing.T) {
 	assert.Equal(t, v2.Minor, int64(19))
 	assert.Equal(t, v2.Patch, int64(1))
 }
+
+func TestCompareWithUnparsableOldVersion(t *testing.T) {
+	assert.Equal(t, GreaterThan("1.0.0", "invalid-version"), true)
+	assert.Equal(t, GreaterThan("0.1.0", "<unknown>"), true)
+	assert.Equal(t, GreaterThan("0.0.1", "commit-hash-abc123"), true)
+
+	assert.Equal(t, GreaterEqual("1.0.0", "invalid-version"), true)
+	assert.Equal(t, GreaterEqual("0.1.0", "<unknown>"), true)
+	assert.Equal(t, GreaterEqual("0.0.1", "commit-hash-abc123"), true)
+
+	assert.Equal(t, GreaterThan("invalid-lima-version", "1.0.0"), true)
+	assert.Equal(t, GreaterEqual("invalid-lima-version", "1.0.0"), true)
+
+	assert.Equal(t, GreaterThan("invalid-lima-version", "invalid-old-version"), true)
+	assert.Equal(t, GreaterEqual("invalid-lima-version", "invalid-old-version"), true)
+}