From 84533aed1aa205099cd0572133faa211c1c22e8e Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 21 May 2026 11:50:37 +0200 Subject: [PATCH] sandbox: decouple env resolution from process; plan9 namespace discovery --- internal/sandbox/config.go | 35 +++++++++++++------------ internal/sandbox/sandbox_test.go | 28 ++++++++++---------- internal/sandbox/wrapper.go | 10 +++---- pkg/tools/execute/server.go | 45 +++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 36 deletions(-) diff --git a/internal/sandbox/config.go b/internal/sandbox/config.go index 386c82d..67d27d1 100644 --- a/internal/sandbox/config.go +++ b/internal/sandbox/config.go @@ -53,8 +53,11 @@ type AdvancedConfig struct { // defaultSandbox is the sandbox config used when none is specified const defaultSandbox = "default" -// expandPath replaces template variables with actual values -func expandPath(pattern, cwd string) string { +// EnvFunc looks up an environment variable by name. +type EnvFunc func(string) string + +// expandPath replaces template variables with actual values using getenv for lookups. +func expandPath(pattern, cwd string, getenv EnvFunc) string { s := pattern s = strings.ReplaceAll(s, "{CWD}", cwd) @@ -62,39 +65,39 @@ func expandPath(pattern, cwd string) string { s = re.ReplaceAllStringFunc(s, func(match string) string { varName := match[1 : len(match)-1] - home, _ := os.UserHomeDir() + home := getenv("HOME") switch varName { case "HOME": if home != "" { return home } case "TMPDIR": - if tmpdir := os.Getenv("TMPDIR"); tmpdir != "" { + if tmpdir := getenv("TMPDIR"); tmpdir != "" { return tmpdir } return "/tmp" case "XDG_CONFIG_HOME": - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" { return xdg } return filepath.Join(home, ".config") case "XDG_DATA_HOME": - if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + if xdg := getenv("XDG_DATA_HOME"); xdg != "" { return xdg } return filepath.Join(home, ".local/share") case "XDG_CACHE_HOME": - if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" { + if xdg := getenv("XDG_CACHE_HOME"); xdg != "" { return xdg } return filepath.Join(home, ".cache") case "XDG_STATE_HOME": - if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { + if xdg := getenv("XDG_STATE_HOME"); xdg != "" { return xdg } return filepath.Join(home, ".local/state") case "XDG_RUNTIME_DIR": - if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { + if xdg := getenv("XDG_RUNTIME_DIR"); xdg != "" { return xdg } return fmt.Sprintf("/run/user/%d", os.Getuid()) @@ -104,20 +107,20 @@ func expandPath(pattern, cwd string) string { return paths.DataDir() } - if val := os.Getenv(varName); val != "" { + if val := getenv(varName); val != "" { return val } return match }) - s = paths.ExpandHome(os.ExpandEnv(s)) + s = paths.ExpandHome(s) return s } // checkPath checks if the given absolute path is allowed by the sandbox config. // If write is true, the path must fall under an RW or RWX entry. // If write is false, any entry (RO, ROX, RW, RWX) grants access. -func checkPath(cfg *Config, path string, write bool, cwd string) error { +func checkPath(cfg *Config, path string, write bool, cwd string, getenv EnvFunc) error { cleaned := filepath.Clean(path) if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { cleaned = resolved @@ -130,17 +133,17 @@ func checkPath(cfg *Config, path string, write bool, cwd string) error { var allowed []string for _, p := range cfg.Filesystem.RW { - allowed = append(allowed, expandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd, getenv)) } for _, p := range cfg.Filesystem.RWX { - allowed = append(allowed, expandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd, getenv)) } if !write { for _, p := range cfg.Filesystem.RO { - allowed = append(allowed, expandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd, getenv)) } for _, p := range cfg.Filesystem.ROX { - allowed = append(allowed, expandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd, getenv)) } } diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index 1279174..ba5e7d7 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -88,7 +88,7 @@ func TestExpandPath(t *testing.T) { for k, v := range tc.env { t.Setenv(k, v) } - got := expandPath(tc.pattern, tc.cwd) + got := expandPath(tc.pattern, tc.cwd, os.Getenv) if got != tc.want { t.Errorf("expandPath(%q, %q) = %q, want %q", tc.pattern, tc.cwd, got, tc.want) } @@ -325,7 +325,7 @@ func TestCheckPath(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - err := checkPath(cfg, tc.path, tc.write, tmpDir) + err := checkPath(cfg, tc.path, tc.write, tmpDir, os.Getenv) if (err != nil) != tc.wantErr { t.Errorf("checkPath(%q, write=%v) error = %v, wantErr %v", tc.path, tc.write, err, tc.wantErr) } @@ -334,7 +334,7 @@ func TestCheckPath(t *testing.T) { t.Run("CWD template resolved", func(t *testing.T) { cfg2 := &Config{Filesystem: FilesystemConfig{RW: []string{"{CWD}"}}} - err := checkPath(cfg2, filepath.Join(tmpDir, "newfile.txt"), true, tmpDir) + err := checkPath(cfg2, filepath.Join(tmpDir, "newfile.txt"), true, tmpDir, os.Getenv) if err != nil { t.Errorf("unexpected error: %v", err) } @@ -346,7 +346,7 @@ func TestCheckPath(t *testing.T) { t.Fatal(err) } cfg3 := &Config{Filesystem: FilesystemConfig{RWX: []string{rwxDir}}} - if err := checkPath(cfg3, filepath.Join(rwxDir, "bin"), true, tmpDir); err != nil { + if err := checkPath(cfg3, filepath.Join(rwxDir, "bin"), true, tmpDir, os.Getenv); err != nil { t.Errorf("unexpected error: %v", err) } }) @@ -469,7 +469,7 @@ env: // ---- expandPath: remaining branches ---- func TestExpandPath_HOME(t *testing.T) { - got := expandPath("{HOME}/test", "/cwd") + got := expandPath("{HOME}/test", "/cwd", os.Getenv) home, _ := os.UserHomeDir() if got != home+"/test" { t.Errorf("expandPath({HOME}/test) = %q; want %q", got, home+"/test") @@ -489,7 +489,7 @@ func TestExpandPath_XDG_Defaults(t *testing.T) { } { t.Run(tc.varName+"_default", func(t *testing.T) { t.Setenv(tc.varName, "") - got := expandPath(tc.pattern, "/cwd") + got := expandPath(tc.pattern, "/cwd", os.Getenv) if got != tc.want { t.Errorf("expandPath(%q) = %q; want %q", tc.pattern, got, tc.want) } @@ -508,7 +508,7 @@ func TestExpandPath_XDG_FromEnv(t *testing.T) { } { t.Run(tc.varName+"_env", func(t *testing.T) { t.Setenv(tc.varName, "/custom/"+tc.varName) - got := expandPath(tc.pattern, "/cwd") + got := expandPath(tc.pattern, "/cwd", os.Getenv) if got != "/custom/"+tc.varName { t.Errorf("expandPath(%q) = %q; want /custom/%s", tc.pattern, got, tc.varName) } @@ -517,11 +517,11 @@ func TestExpandPath_XDG_FromEnv(t *testing.T) { } func TestExpandPath_OlliePaths(t *testing.T) { - got1 := expandPath("{OLLIE_CFG_PATH}", "/cwd") + got1 := expandPath("{OLLIE_CFG_PATH}", "/cwd", os.Getenv) if got1 == "{OLLIE_CFG_PATH}" || got1 == "" { t.Errorf("OLLIE_CFG_PATH not expanded: %q", got1) } - got2 := expandPath("{OLLIE_DATA_PATH}", "/cwd") + got2 := expandPath("{OLLIE_DATA_PATH}", "/cwd", os.Getenv) if got2 == "{OLLIE_DATA_PATH}" || got2 == "" { t.Errorf("OLLIE_DATA_PATH not expanded: %q", got2) } @@ -541,7 +541,7 @@ func TestCheckPath_SymlinkResolved(t *testing.T) { cfg := &Config{Filesystem: FilesystemConfig{RW: []string{realDir}}} // Access via symlink — EvalSymlinks resolves link/file to real/file - if err := checkPath(cfg, filepath.Join(link, "file"), true, tmpDir); err != nil { + if err := checkPath(cfg, filepath.Join(link, "file"), true, tmpDir, os.Getenv); err != nil { t.Errorf("symlink path should be allowed: %v", err) } } @@ -552,10 +552,10 @@ func TestCheckPath_ROX_ReadAllowed(t *testing.T) { os.MkdirAll(roxDir, 0755) cfg := &Config{Filesystem: FilesystemConfig{ROX: []string{roxDir}}} - if err := checkPath(cfg, filepath.Join(roxDir, "bin"), false, tmpDir); err != nil { + if err := checkPath(cfg, filepath.Join(roxDir, "bin"), false, tmpDir, os.Getenv); err != nil { t.Errorf("ROX read should be allowed: %v", err) } - if err := checkPath(cfg, filepath.Join(roxDir, "bin"), true, tmpDir); err == nil { + if err := checkPath(cfg, filepath.Join(roxDir, "bin"), true, tmpDir, os.Getenv); err == nil { t.Error("ROX write should be denied") } } @@ -581,7 +581,7 @@ func TestWrapCommand_LandrunUnavailable(t *testing.T) { defer func() { isAvailableFn = old }() cfg := &Config{} - _, err := WrapCommand(cfg, []string{"echo", "hi"}, "/tmp") + _, err := WrapCommand(cfg, []string{"echo", "hi"}, "/tmp", os.Getenv) if err == nil { t.Fatal("expected error when landrun unavailable") } @@ -696,7 +696,7 @@ func TestIsSuperpowerdRunning_SocketExists(t *testing.T) { func mustWrapCommand(t *testing.T, cfg *Config, cmd []string, cwd string) []string { t.Helper() - got, err := WrapCommand(cfg, cmd, cwd) + got, err := WrapCommand(cfg, cmd, cwd, os.Getenv) if err != nil { t.Fatalf("WrapCommand failed: %v", err) } diff --git a/internal/sandbox/wrapper.go b/internal/sandbox/wrapper.go index ee1c4e0..2e37679 100644 --- a/internal/sandbox/wrapper.go +++ b/internal/sandbox/wrapper.go @@ -26,7 +26,7 @@ var ErrNotAvailable = fmt.Errorf("SANDBOX FAILURE: landrun is not installed or n // WrapCommand wraps a command with landrun based on the configuration. // Returns an error if landrun is not available. -func WrapCommand(cfg *Config, originalCmd []string, cwd string) ([]string, error) { +func WrapCommand(cfg *Config, originalCmd []string, cwd string, getenv EnvFunc) ([]string, error) { if !isAvailableFn() { return nil, ErrNotAvailable } @@ -50,25 +50,25 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) ([]string, error var entries []pathEntry for _, path := range cfg.Filesystem.RO { - expanded := expandPath(path, cwd) + expanded := expandPath(path, cwd, getenv) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--ro"}) } } for _, path := range cfg.Filesystem.ROX { - expanded := expandPath(path, cwd) + expanded := expandPath(path, cwd, getenv) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--rox"}) } } for _, path := range cfg.Filesystem.RW { - expanded := expandPath(path, cwd) + expanded := expandPath(path, cwd, getenv) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--rw"}) } } for _, path := range cfg.Filesystem.RWX { - expanded := expandPath(path, cwd) + expanded := expandPath(path, cwd, getenv) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--rwx"}) } diff --git a/pkg/tools/execute/server.go b/pkg/tools/execute/server.go index ddde08a..df061f0 100644 --- a/pkg/tools/execute/server.go +++ b/pkg/tools/execute/server.go @@ -585,10 +585,29 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti default: return "", fmt.Errorf("unsupported language: %s (supported: bash, python3, perl, awk, sed, ed, jq, expect, bc, lua)", language) } + e.envMu.RLock() + envMap := make(map[string]string, len(e.envExtra)) + for k, v := range e.envExtra { + envMap[k] = v + } + e.envMu.RUnlock() + for _, ev := range os.Environ() { + k, v, _ := strings.Cut(ev, "=") + if _, ok := envMap[k]; !ok { + envMap[k] = v + } + } + if _, ok := envMap["NAMESPACE"]; !ok { + if ns := plan9Namespace(envMap); ns != "" { + envMap["NAMESPACE"] = ns + } + } + getenv := func(key string) string { return envMap[key] } + if e.Yolo { cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...) } else { - wrapped, wrapErr := sandbox.WrapCommand(cfg, interpreter, workDir) + wrapped, wrapErr := sandbox.WrapCommand(cfg, interpreter, workDir, getenv) if wrapErr != nil { return "", wrapErr } @@ -668,4 +687,28 @@ func prependOlliePath(env []string, ollie string) []string { return result } +// plan9Namespace computes the plan9port namespace directory from the environment. +// Convention: $NAMESPACE if set, else /tmp/ns.. where display is +// $DISPLAY with trailing .0 stripped and / replaced by _. +func plan9Namespace(env map[string]string) string { + if ns := env["NAMESPACE"]; ns != "" { + return ns + } + user := env["USER"] + if user == "" { + return "" + } + disp := env["DISPLAY"] + if disp == "" { + return "" + } + // Canonicalize: strip trailing .0 + if strings.HasSuffix(disp, ".0") { + disp = disp[:len(disp)-2] + } + // Replace / with _ + disp = strings.ReplaceAll(disp, "/", "_") + return "/tmp/ns." + user + "." + disp +} + var _ tools.Server = (*Server)(nil) // compile-time interface check