sandbox: decouple env resolution from process; plan9 namespace discovery

This commit is contained in:
Levi Neely 2026-05-21 11:50:37 +02:00
parent 4188d5946b
commit 84533aed1a
4 changed files with 82 additions and 36 deletions

View File

@ -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))
}
}

View File

@ -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)
}

View File

@ -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"})
}

View File

@ -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.<user>.<display> 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