From 9fb2e1f4312074a16b969fbdd8338890cd22ab94 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 3 Apr 2026 10:56:11 -0400 Subject: [PATCH 1/6] feat(interp): expose ALLOWED_PATHS environment variable Set ALLOWED_PATHS in the interpreter's environment during construction so users/agents can discover accessible directories via echo $ALLOWED_PATHS. The value is a filepath.ListSeparator-delimited list of resolved absolute paths (skipping non-existent ones). Co-Authored-By: Claude Opus 4.6 (1M context) --- allowedpaths/sandbox.go | 12 +++ analysis/symbols_interp.go | 102 +++++++++--------- interp/allowed_paths_internal_test.go | 98 +++++++++++++++++ interp/api.go | 21 ++++ .../allowed_paths/allowed_paths_env_var.yaml | 18 ++++ 5 files changed, 201 insertions(+), 50 deletions(-) create mode 100644 tests/scenarios/shell/allowed_paths/allowed_paths_env_var.yaml 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..c73441c2 100644 --- a/interp/api.go +++ b/interp/api.go @@ -18,6 +18,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "sync/atomic" "time" @@ -250,6 +251,14 @@ func New(opts ...RunnerOption) (*Runner, error) { if r.hostPrefix != "" && r.sandbox != nil { r.sandbox.SetHostPrefix(r.hostPrefix) } + // Expose allowed paths as an environment variable so users/agents can + // discover which directories are accessible (e.g. echo $ALLOWED_PATHS). + if r.sandbox != nil { + if paths := r.sandbox.Paths(); len(paths) > 0 { + pair := "ALLOWED_PATHS=" + strings.Join(paths, string(filepath.ListSeparator)) + r.Env = expand.ListEnviron(append(environToList(r.Env), pair)...) + } + } // Flush any sandbox warnings now that stderr is guaranteed to be set. if len(r.sandboxWarnings) > 0 { r.stderr.Write(r.sandboxWarnings) @@ -259,6 +268,18 @@ func New(opts ...RunnerOption) (*Runner, error) { return r, nil } +// environToList converts an Environ back to "KEY=value" pairs. +func environToList(env expand.Environ) []string { + var pairs []string + env.Each(func(name string, vr expand.Variable) bool { + if vr.Exported && vr.Kind == expand.String { + pairs = append(pairs, name+"="+vr.Str) + } + return true + }) + return pairs +} + // RunnerOption can be passed to [New] to alter a [Runner]'s behaviour. type RunnerOption func(*Runner) error 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 From dd468bb34edf497bb61d220590756a360afc8d76 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 3 Apr 2026 11:26:07 -0400 Subject: [PATCH 2/6] refactor(interp): build Env from stored pairs instead of rebuilding Store raw env pairs on runnerConfig and build ListEnviron once in New() with ALLOWED_PATHS appended. This avoids the environToList roundtrip that could lose state from custom Environ implementations. Co-Authored-By: Claude Opus 4.6 (1M context) --- interp/api.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/interp/api.go b/interp/api.go index c73441c2..52254de1 100644 --- a/interp/api.go +++ b/interp/api.go @@ -38,6 +38,11 @@ type runnerConfig struct { // not be nil. It can only be set via [Env]. Env expand.Environ + // envPairs stores the raw "KEY=value" pairs from the Env option so + // that internal pairs (like ALLOWED_PATHS) can be appended before + // building the final ListEnviron in New(). + envPairs []string + // execHandler is responsible for executing programs. It must not be nil. execHandler ExecHandlerFunc @@ -231,11 +236,16 @@ 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() + // Build the environment from stored pairs plus any internal variables. + // ALLOWED_PATHS is injected here so it's part of the immutable base + // environment regardless of option ordering. + pairs := r.envPairs + if r.sandbox != nil { + if paths := r.sandbox.Paths(); len(paths) > 0 { + pairs = append(pairs, "ALLOWED_PATHS="+strings.Join(paths, string(filepath.ListSeparator))) + } } + r.Env = expand.ListEnviron(pairs...) if r.Dir == "" { dir, err := os.Getwd() if err != nil { @@ -330,7 +340,7 @@ func stdinFile(ctx context.Context, r io.Reader) (*os.File, error) { // an empty environment (no host environment variables are inherited). func Env(pairs ...string) RunnerOption { return func(r *Runner) error { - r.Env = expand.ListEnviron(pairs...) + r.envPairs = pairs return nil } } From 2f1bcfaf4b1b807a6c89050797684210b3e0b296 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 3 Apr 2026 13:48:10 -0400 Subject: [PATCH 3/6] refactor(interp): set ALLOWED_PATHS via setVarString in Reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of rebuilding Env at construction time, set ALLOWED_PATHS through the writeEnv overlay in Reset — the same mechanism used for PWD, IFS, and OPTIND. This removes the envPairs field, the environToList helper, and all Environ rebuilding logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- interp/api.go | 43 +++++++++---------------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/interp/api.go b/interp/api.go index 52254de1..818898cf 100644 --- a/interp/api.go +++ b/interp/api.go @@ -38,11 +38,6 @@ type runnerConfig struct { // not be nil. It can only be set via [Env]. Env expand.Environ - // envPairs stores the raw "KEY=value" pairs from the Env option so - // that internal pairs (like ALLOWED_PATHS) can be appended before - // building the final ListEnviron in New(). - envPairs []string - // execHandler is responsible for executing programs. It must not be nil. execHandler ExecHandlerFunc @@ -236,16 +231,10 @@ func New(opts ...RunnerOption) (*Runner, error) { } } - // Build the environment from stored pairs plus any internal variables. - // ALLOWED_PATHS is injected here so it's part of the immutable base - // environment regardless of option ordering. - pairs := r.envPairs - if r.sandbox != nil { - if paths := r.sandbox.Paths(); len(paths) > 0 { - pairs = append(pairs, "ALLOWED_PATHS="+strings.Join(paths, string(filepath.ListSeparator))) - } + // Default to an empty environment to avoid propagating parent env vars. + if r.Env == nil { + r.Env = expand.ListEnviron() } - r.Env = expand.ListEnviron(pairs...) if r.Dir == "" { dir, err := os.Getwd() if err != nil { @@ -261,14 +250,6 @@ func New(opts ...RunnerOption) (*Runner, error) { if r.hostPrefix != "" && r.sandbox != nil { r.sandbox.SetHostPrefix(r.hostPrefix) } - // Expose allowed paths as an environment variable so users/agents can - // discover which directories are accessible (e.g. echo $ALLOWED_PATHS). - if r.sandbox != nil { - if paths := r.sandbox.Paths(); len(paths) > 0 { - pair := "ALLOWED_PATHS=" + strings.Join(paths, string(filepath.ListSeparator)) - r.Env = expand.ListEnviron(append(environToList(r.Env), pair)...) - } - } // Flush any sandbox warnings now that stderr is guaranteed to be set. if len(r.sandboxWarnings) > 0 { r.stderr.Write(r.sandboxWarnings) @@ -279,17 +260,6 @@ func New(opts ...RunnerOption) (*Runner, error) { } // environToList converts an Environ back to "KEY=value" pairs. -func environToList(env expand.Environ) []string { - var pairs []string - env.Each(func(name string, vr expand.Variable) bool { - if vr.Exported && vr.Kind == expand.String { - pairs = append(pairs, name+"="+vr.Str) - } - return true - }) - return pairs -} - // RunnerOption can be passed to [New] to alter a [Runner]'s behaviour. type RunnerOption func(*Runner) error @@ -340,7 +310,7 @@ func stdinFile(ctx context.Context, r io.Reader) (*os.File, error) { // an empty environment (no host environment variables are inherited). func Env(pairs ...string) RunnerOption { return func(r *Runner) error { - r.envPairs = pairs + r.Env = expand.ListEnviron(pairs...) return nil } } @@ -460,6 +430,11 @@ 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 { + if paths := r.sandbox.Paths(); len(paths) > 0 { + r.setVarString("ALLOWED_PATHS", strings.Join(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 From 76fecad9f62af450413a5812101fb397c15cb0a3 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 3 Apr 2026 13:57:58 -0400 Subject: [PATCH 4/6] fix(interp): always set ALLOWED_PATHS when sandbox exists Set ALLOWED_PATHS to empty string when the sandbox has zero valid roots, preventing a spoofed value from Env() leaking through. Co-Authored-By: Claude Opus 4.6 (1M context) --- interp/api.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/interp/api.go b/interp/api.go index 818898cf..c890e088 100644 --- a/interp/api.go +++ b/interp/api.go @@ -431,9 +431,7 @@ func (r *Runner) Reset() { r.setVarString("IFS", " \t\n") r.setVarString("OPTIND", "1") if r.sandbox != nil { - if paths := r.sandbox.Paths(); len(paths) > 0 { - r.setVarString("ALLOWED_PATHS", strings.Join(paths, string(filepath.ListSeparator))) - } + r.setVarString("ALLOWED_PATHS", strings.Join(r.sandbox.Paths(), string(filepath.ListSeparator))) } // Reset the total-bytes counter so that the interpreter's own initial From 1667c84de9864a0904e53fada0c853848e964865 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 3 Apr 2026 14:32:58 -0400 Subject: [PATCH 5/6] docs: document ALLOWED_PATHS environment variable in SHELL_FEATURES.md Co-Authored-By: Claude Opus 4.6 (1M context) --- SHELL_FEATURES.md | 1 + 1 file changed, 1 insertion(+) 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 From 7fcf23111d6bea609a7240c8ca3181c1bad345a8 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 3 Apr 2026 16:08:46 -0400 Subject: [PATCH 6/6] style: remove style comment --- interp/api.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/interp/api.go b/interp/api.go index c890e088..a421374e 100644 --- a/interp/api.go +++ b/interp/api.go @@ -259,7 +259,6 @@ func New(opts ...RunnerOption) (*Runner, error) { return r, nil } -// environToList converts an Environ back to "KEY=value" pairs. // RunnerOption can be passed to [New] to alter a [Runner]'s behaviour. type RunnerOption func(*Runner) error @@ -435,9 +434,11 @@ func (r *Runner) Reset() { } // 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 }