diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 05417fb4..0d341544 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -115,6 +115,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ Empty by default — no parent environment variables are inherited - ✅ Caller-provided variables via the `Env` option - ✅ `IFS` is set to space/tab/newline by default +- ✅ `ALLOWED_PATHS` — when `AllowedPaths` is configured, set to a `filepath.ListSeparator`-delimited list of resolved allowed directories (`:` on Unix, `;` on Windows) - ❌ No automatic inheritance from the host process - ❌ `export`, `readonly` are blocked diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 2d04d9cf..d158918a 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -623,6 +623,18 @@ func (s *Sandbox) HostPrefix() string { return s.hostPrefix } +// Paths returns the resolved absolute paths of all allowed directories. +func (s *Sandbox) Paths() []string { + if s == nil { + return nil + } + paths := make([]string, len(s.roots)) + for i, r := range s.roots { + paths[i] = r.absPath + } + return paths +} + // Close releases all os.Root file descriptors. It is safe to call multiple times. func (s *Sandbox) Close() error { if s == nil { diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index ffd2ff8d..3c52e7d5 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -17,56 +17,58 @@ package analysis // // The permanently banned packages (reflect, unsafe) apply here too. var interpAllowedSymbols = []string{ - "bytes.Buffer", // 🟢 in-memory byte buffer; pure data structure, no I/O. - "context.Background", // 🟢 returns the empty background context; used in StdIO option where no run-scoped context is available. - "context.CancelFunc", // 🟢 function type returned by WithTimeout/WithCancel; pure function type, no side effects. - "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. - "context.WithTimeout", // 🟢 derives a context with a deadline; needed for execution timeout support. - "context.WithValue", // 🟢 derives a context carrying a key-value pair; pure function. - "errors.As", // 🟢 error type assertion; pure function, no I/O. - "errors.New", // 🟢 creates a sentinel error value; pure function, no I/O. - "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. - "fmt.Fprintf", // 🟠 formatted write to an io.Writer; delegates to Write, no filesystem access. - "fmt.Fprintln", // 🟠 writes to an io.Writer with newline; delegates to Write, no filesystem access. - "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. - "io.Closer", // 🟢 interface type for closing; no side effects. - "io.Copy", // 🟠 copies from Reader to Writer; no filesystem access, delegates to Read/Write. - "io.Discard", // 🟢 write sink that discards all data; no side effects. - "io.LimitReader", // 🟢 wraps a Reader with a byte cap; pure function, no I/O. - "io.Reader", // 🟢 interface type for reading; no side effects. - "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. - "io.Writer", // 🟢 interface type for writing; no side effects. - "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. - "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. - "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. - "maps.Insert", // 🟢 inserts all key-value pairs from one map into another; pure function. - "os.DirEntry", // 🟢 type alias for fs.DirEntry; no side effects. - "os.File", // 🟠 file handle type; interpreter needs file I/O for redirects and pipes. - "os.FileMode", // 🟢 file permission bits type; pure type. - "os.Getwd", // 🟠 returns current working directory; read-only. - "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. - "os.PathError", // 🟢 error type wrapping path and operation; pure type. - "os.Pipe", // 🟠 creates an OS pipe pair; needed for shell pipelines. - "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. - "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. - "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. - "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. - "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. - "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. - "strings.NewReader", // 🟢 wraps a string as an io.Reader; pure function, no I/O; used by ParseScript. - "strings.Index", // 🟢 finds substring index; pure function, no I/O. - "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. - "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. - "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. - "strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O. - "strings.TrimLeft", // 🟢 trims leading characters; pure function, no I/O. - "sync.Mutex", // 🟢 mutual exclusion lock; concurrency primitive, no I/O. - "sync.Once", // 🟢 ensures a function runs exactly once; concurrency primitive, no I/O. - "sync.WaitGroup", // 🟢 waits for goroutines to finish; concurrency primitive, no I/O. - "sync/atomic.Int64", // 🟢 atomic int64 counter; concurrency primitive, no I/O. - "time.Duration", // 🟢 numeric duration type; pure type, no side effects. - "time.Now", // 🟠 returns current time; read-only, no mutation. - "time.Time", // 🟢 time value type; pure data, no side effects. + "bytes.Buffer", // 🟢 in-memory byte buffer; pure data structure, no I/O. + "context.Background", // 🟢 returns the empty background context; used in StdIO option where no run-scoped context is available. + "context.CancelFunc", // 🟢 function type returned by WithTimeout/WithCancel; pure function type, no side effects. + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "context.WithTimeout", // 🟢 derives a context with a deadline; needed for execution timeout support. + "context.WithValue", // 🟢 derives a context carrying a key-value pair; pure function. + "errors.As", // 🟢 error type assertion; pure function, no I/O. + "errors.New", // 🟢 creates a sentinel error value; pure function, no I/O. + "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. + "fmt.Fprintf", // 🟠 formatted write to an io.Writer; delegates to Write, no filesystem access. + "fmt.Fprintln", // 🟠 writes to an io.Writer with newline; delegates to Write, no filesystem access. + "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "io.Closer", // 🟢 interface type for closing; no side effects. + "io.Copy", // 🟠 copies from Reader to Writer; no filesystem access, delegates to Read/Write. + "io.Discard", // 🟢 write sink that discards all data; no side effects. + "io.LimitReader", // 🟢 wraps a Reader with a byte cap; pure function, no I/O. + "io.Reader", // 🟢 interface type for reading; no side effects. + "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. + "io.Writer", // 🟢 interface type for writing; no side effects. + "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. + "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. + "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. + "maps.Insert", // 🟢 inserts all key-value pairs from one map into another; pure function. + "os.DirEntry", // 🟢 type alias for fs.DirEntry; no side effects. + "os.File", // 🟠 file handle type; interpreter needs file I/O for redirects and pipes. + "os.FileMode", // 🟢 file permission bits type; pure type. + "os.Getwd", // 🟠 returns current working directory; read-only. + "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. + "os.PathError", // 🟢 error type wrapping path and operation; pure type. + "os.Pipe", // 🟠 creates an OS pipe pair; needed for shell pipelines. + "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. + "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. + "path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant. + "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. + "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. + "strings.Builder", // 🟢 efficient string concatenation; pure in-memory buffer, no I/O. + "strings.ContainsRune", // 🟢 checks if a rune is in a string; pure function, no I/O. + "strings.NewReader", // 🟢 wraps a string as an io.Reader; pure function, no I/O; used by ParseScript. + "strings.Index", // 🟢 finds substring index; pure function, no I/O. + "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. + "strings.HasSuffix", // 🟢 pure function for suffix matching; no I/O. + "strings.Join", // 🟢 joins string slices; pure function, no I/O. + "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. + "strings.ToUpper", // 🟢 converts string to uppercase; pure function, no I/O. + "strings.TrimLeft", // 🟢 trims leading characters; pure function, no I/O. + "sync.Mutex", // 🟢 mutual exclusion lock; concurrency primitive, no I/O. + "sync.Once", // 🟢 ensures a function runs exactly once; concurrency primitive, no I/O. + "sync.WaitGroup", // 🟢 waits for goroutines to finish; concurrency primitive, no I/O. + "sync/atomic.Int64", // 🟢 atomic int64 counter; concurrency primitive, no I/O. + "time.Duration", // 🟢 numeric duration type; pure type, no side effects. + "time.Now", // 🟠 returns current time; read-only, no mutation. + "time.Time", // 🟢 time value type; pure data, no side effects. // --- mvdan.cc/sh/v3/expand --- (shell word expansion library) diff --git a/interp/allowed_paths_internal_test.go b/interp/allowed_paths_internal_test.go index 869b79f5..542eedec 100644 --- a/interp/allowed_paths_internal_test.go +++ b/interp/allowed_paths_internal_test.go @@ -259,3 +259,101 @@ func TestHostPrefixDefaultWhenNotSet(t *testing.T) { assert.Empty(t, runner.sandbox.HostPrefix()) } + +// TestAllowedPathsEnvVar verifies that ALLOWED_PATHS is set in the +// interpreter's environment with the resolved absolute paths. +func TestAllowedPathsEnvVar(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + + stdout, _, _ := runScriptInternal(t, `echo $ALLOWED_PATHS`, dir1, + AllowedPaths([]string{dir1, dir2}), + ) + + expected := dir1 + string(filepath.ListSeparator) + dir2 + assert.Equal(t, expected+"\n", stdout) +} + +// TestAllowedPathsEnvVarSinglePath verifies ALLOWED_PATHS with one path. +func TestAllowedPathsEnvVarSinglePath(t *testing.T) { + dir := t.TempDir() + + stdout, _, _ := runScriptInternal(t, `echo $ALLOWED_PATHS`, dir, + AllowedPaths([]string{dir}), + ) + + assert.Equal(t, dir+"\n", stdout) +} + +// TestAllowedPathsEnvVarManyDirs verifies ALLOWED_PATHS with several directories. +func TestAllowedPathsEnvVarManyDirs(t *testing.T) { + dirs := make([]string, 5) + for i := range dirs { + dirs[i] = t.TempDir() + } + + stdout, _, _ := runScriptInternal(t, `echo $ALLOWED_PATHS`, dirs[0], + AllowedPaths(dirs), + ) + + expected := strings.Join(dirs, string(filepath.ListSeparator)) + assert.Equal(t, expected+"\n", stdout) +} + +// TestAllowedPathsEnvVarNestedDirs verifies ALLOWED_PATHS with deeply +// nested directories. +func TestAllowedPathsEnvVarNestedDirs(t *testing.T) { + root := t.TempDir() + nested1 := filepath.Join(root, "a", "b", "c") + nested2 := filepath.Join(root, "x", "y") + require.NoError(t, os.MkdirAll(nested1, 0755)) + require.NoError(t, os.MkdirAll(nested2, 0755)) + + stdout, _, _ := runScriptInternal(t, `echo $ALLOWED_PATHS`, root, + AllowedPaths([]string{nested1, nested2}), + ) + + expected := nested1 + string(filepath.ListSeparator) + nested2 + assert.Equal(t, expected+"\n", stdout) +} + +// TestAllowedPathsEnvVarParentAndChild verifies ALLOWED_PATHS when both +// a parent and child directory are allowed. +func TestAllowedPathsEnvVarParentAndChild(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "sub", "dir") + require.NoError(t, os.MkdirAll(child, 0755)) + + stdout, _, _ := runScriptInternal(t, `echo $ALLOWED_PATHS`, root, + AllowedPaths([]string{root, child}), + ) + + expected := root + string(filepath.ListSeparator) + child + assert.Equal(t, expected+"\n", stdout) +} + +// TestAllowedPathsEnvVarSkipsNonexistent verifies that ALLOWED_PATHS only +// contains directories that were successfully opened. +func TestAllowedPathsEnvVarSkipsNonexistent(t *testing.T) { + dir := t.TempDir() + + stdout, _, _ := runScriptInternal(t, `echo $ALLOWED_PATHS`, dir, + AllowedPaths([]string{"/nonexistent/path", dir}), + ) + + assert.Equal(t, dir+"\n", stdout) +} + +// TestAllowedPathsEnvVarNotSetWithoutSandbox verifies that ALLOWED_PATHS +// is not set when AllowedPaths is not configured. +func TestAllowedPathsEnvVarNotSetWithoutSandbox(t *testing.T) { + dir := t.TempDir() + + runner, err := New() + require.NoError(t, err) + defer runner.Close() + + runner.Dir = dir + v := runner.Env.Get("ALLOWED_PATHS") + assert.False(t, v.IsSet(), "ALLOWED_PATHS should not be set without AllowedPaths") +} diff --git a/interp/api.go b/interp/api.go index 9fec73ba..a421374e 100644 --- a/interp/api.go +++ b/interp/api.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "sync/atomic" "time" @@ -230,7 +231,6 @@ func New(opts ...RunnerOption) (*Runner, error) { } } - // Set the default fallbacks, if necessary. // Default to an empty environment to avoid propagating parent env vars. if r.Env == nil { r.Env = expand.ListEnviron() @@ -429,11 +429,16 @@ func (r *Runner) Reset() { // blocks all external execution, limiting the practical impact of this vector. r.setVarString("IFS", " \t\n") r.setVarString("OPTIND", "1") + if r.sandbox != nil { + r.setVarString("ALLOWED_PATHS", strings.Join(r.sandbox.Paths(), string(filepath.ListSeparator))) + } // Reset the total-bytes counter so that the interpreter's own initial - // variable assignments (PWD, IFS, OPTIND above) do not count against the - // user-visible MaxTotalVarsBytes cap. Those values are small and bounded; - // only the variables that a script itself creates or modifies should count. + // variable assignments (PWD, IFS, OPTIND, ALLOWED_PATHS above) do not + // count against the user-visible MaxTotalVarsBytes cap. Those values are + // small and bounded; only the variables that a script itself creates or + // modifies should count. ALLOWED_PATHS is operator-configured and + // typically a few hundred bytes, so this is safe. if ov, ok := r.writeEnv.(*overlayEnviron); ok { ov.totalBytes = 0 } diff --git a/tests/scenarios/shell/allowed_paths/allowed_paths_env_var.yaml b/tests/scenarios/shell/allowed_paths/allowed_paths_env_var.yaml new file mode 100644 index 00000000..8e033006 --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/allowed_paths_env_var.yaml @@ -0,0 +1,18 @@ +description: ALLOWED_PATHS environment variable lists resolved allowed directories +# skip: ALLOWED_PATHS env var is an rshell-specific feature +skip_assert_against_bash: true +setup: + files: + - path: dir1/file.txt + content: "a\n" + - path: dir2/file.txt + content: "b\n" +input: + allowed_paths: ["dir1", "dir2"] + script: |+ + echo $ALLOWED_PATHS +expect: + stdout_contains: + - "dir1" + - "dir2" + exit_code: 0