diff --git a/docs/configuration.md b/docs/configuration.md index 7ccd1f8..d34729f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,6 +121,7 @@ Use this when you need to support apps that don't respect proxy environment vari | Field | Description | |-------|-------------| | `wslInterop` | WSL interop support. `null` (default) = auto-detect, `true` = force on, `false` = force off. When active, auto-allows execute on `/init`. | +| `extraReadableMounts` | Linux-only absolute mount roots (must start with `/`) to expose when using bubblewrap's non-recursive root bind (for example `/run` or `/nix`). Fence also includes descendant submounts under each root. | | `allowRead` | Paths to allow reading and directory listing (Landlock: `READ_FILE + READ_DIR + EXECUTE`) | | `allowExecute` | Paths to allow executing only (Landlock: `READ_FILE + EXECUTE`, no directory listing) | | `denyRead` | Paths to deny reading (deny-only pattern) | @@ -145,6 +146,28 @@ Fence provides three levels of filesystem access, from most restrictive to least System paths like `/usr`, `/lib`, `/bin`, `/etc` are always readable — you don't need to add them. +### NixOS / Nix Example + +On systems with many sub-mounts (for example NixOS), bubblewrap's root bind is non-recursive. Paths like `/nix` or `/run` can appear empty inside the sandbox unless you expose those mount roots explicitly. + +Use `extraReadableMounts` for those roots: + +```json +{ + "extends": "code", + "filesystem": { + "extraReadableMounts": [ + "/nix", + "/run" + ] + } +} +``` + +- Entries must be absolute paths (start with `/`) +- Fence includes descendant submounts under each configured root +- Keep this list minimal; only add roots you actually need + ### WSL (Windows Subsystem for Linux) Example On WSL2, fence auto-detects the environment and allows `/init` (the WSL binfmt_misc interpreter) automatically. You only need to add the specific Windows executables and paths you use: diff --git a/docs/schema/fence.schema.json b/docs/schema/fence.schema.json index 91f0a10..ffe68b7 100644 --- a/docs/schema/fence.schema.json +++ b/docs/schema/fence.schema.json @@ -76,6 +76,12 @@ }, "type": "array" }, + "extraReadableMounts": { + "items": { + "type": "string" + }, + "type": "array" + }, "wslInterop": { "type": [ "boolean", diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c342a94..d8521c9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -135,6 +135,30 @@ Notes: - `--shell-login` uses `-lc` so login startup files are loaded. - If your required `PATH` is already exported before launching fence, `--shell user` without `--shell-login` may be enough. +## NixOS: `/nix` or `/run` appears empty inside sandbox + +On NixOS and similar setups with many sub-mounts, bubblewrap's root bind is non-recursive. Some mount roots (commonly `/nix` and `/run`) may not be visible unless explicitly exposed. + +Quick way to choose what to add: + +- Run with debug: `fence -d ` +- Find the missing path in the error output +- Add only that path's top-level mount root: + - missing `/nix/store/...` -> add `/nix` + - missing `/run/...` -> add `/run` + +Fix: add required roots to `filesystem.extraReadableMounts`: + +```json +{ + "filesystem": { + "extraReadableMounts": ["/nix", "/run"] + } +} +``` + +Then rerun with debug mode (`fence -d `) to confirm the mount paths are bound. See [Filesystem Configuration](/configuration#filesystem-configuration) for details. + ## Node.js HTTP(S) doesn't use proxy env vars by default Node's built-in `http`/`https` modules ignore `HTTP_PROXY`/`HTTPS_PROXY`. diff --git a/internal/config/config.go b/internal/config/config.go index 07bd9b5..457c82c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,14 +37,15 @@ type NetworkConfig struct { // FilesystemConfig defines filesystem restrictions. type FilesystemConfig struct { - DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead - WSLInterop *bool `json:"wslInterop,omitempty"` // If nil, auto-detect WSL and allow /init; true/false to override - AllowRead []string `json:"allowRead"` // Paths to allow reading - AllowExecute []string `json:"allowExecute"` // Paths to allow executing (read+execute only, no directory listing) - DenyRead []string `json:"denyRead"` - AllowWrite []string `json:"allowWrite"` - DenyWrite []string `json:"denyWrite"` - AllowGitConfig bool `json:"allowGitConfig,omitempty"` + DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead + WSLInterop *bool `json:"wslInterop,omitempty"` // If nil, auto-detect WSL and allow /init; true/false to override + ExtraReadableMounts []string `json:"extraReadableMounts,omitempty"` // Linux-only: additional mount roots to expose (and include descendant submounts) + AllowRead []string `json:"allowRead"` // Paths to allow reading + AllowExecute []string `json:"allowExecute"` // Paths to allow executing (read+execute only, no directory listing) + DenyRead []string `json:"denyRead"` + AllowWrite []string `json:"allowWrite"` + DenyWrite []string `json:"denyWrite"` + AllowGitConfig bool `json:"allowGitConfig,omitempty"` } // CommandConfig defines command restrictions. @@ -212,6 +213,14 @@ func (c *Config) Validate() error { if slices.Contains(c.Filesystem.AllowRead, "") { return errors.New("filesystem.allowRead contains empty path") } + if slices.Contains(c.Filesystem.ExtraReadableMounts, "") { + return errors.New("filesystem.extraReadableMounts contains empty path") + } + for _, p := range c.Filesystem.ExtraReadableMounts { + if !filepath.IsAbs(p) { + return fmt.Errorf("filesystem.extraReadableMounts path must be absolute: %q", p) + } + } if slices.Contains(c.Filesystem.AllowExecute, "") { return errors.New("filesystem.allowExecute contains empty path") } @@ -470,11 +479,12 @@ func Merge(base, override *Config) *Config { WSLInterop: mergeOptionalBool(base.Filesystem.WSLInterop, override.Filesystem.WSLInterop), // Append slices - AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead), - AllowExecute: mergeStrings(base.Filesystem.AllowExecute, override.Filesystem.AllowExecute), - DenyRead: mergeStrings(base.Filesystem.DenyRead, override.Filesystem.DenyRead), - AllowWrite: mergeStrings(base.Filesystem.AllowWrite, override.Filesystem.AllowWrite), - DenyWrite: mergeStrings(base.Filesystem.DenyWrite, override.Filesystem.DenyWrite), + ExtraReadableMounts: mergeStrings(base.Filesystem.ExtraReadableMounts, override.Filesystem.ExtraReadableMounts), + AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead), + AllowExecute: mergeStrings(base.Filesystem.AllowExecute, override.Filesystem.AllowExecute), + DenyRead: mergeStrings(base.Filesystem.DenyRead, override.Filesystem.DenyRead), + AllowWrite: mergeStrings(base.Filesystem.AllowWrite, override.Filesystem.AllowWrite), + DenyWrite: mergeStrings(base.Filesystem.DenyWrite, override.Filesystem.DenyWrite), // Boolean fields: override wins if set AllowGitConfig: base.Filesystem.AllowGitConfig || override.Filesystem.AllowGitConfig, diff --git a/internal/config/config_file.go b/internal/config/config_file.go index 5597ddf..b045c93 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -28,14 +28,15 @@ type cleanNetworkConfig struct { // cleanFilesystemConfig is used for JSON output with omitempty to skip empty fields. type cleanFilesystemConfig struct { - DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` - WSLInterop *bool `json:"wslInterop,omitempty"` - AllowRead []string `json:"allowRead,omitempty"` - AllowExecute []string `json:"allowExecute,omitempty"` - DenyRead []string `json:"denyRead,omitempty"` - AllowWrite []string `json:"allowWrite,omitempty"` - DenyWrite []string `json:"denyWrite,omitempty"` - AllowGitConfig bool `json:"allowGitConfig,omitempty"` + DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` + WSLInterop *bool `json:"wslInterop,omitempty"` + ExtraReadableMounts []string `json:"extraReadableMounts,omitempty"` + AllowRead []string `json:"allowRead,omitempty"` + AllowExecute []string `json:"allowExecute,omitempty"` + DenyRead []string `json:"denyRead,omitempty"` + AllowWrite []string `json:"allowWrite,omitempty"` + DenyWrite []string `json:"denyWrite,omitempty"` + AllowGitConfig bool `json:"allowGitConfig,omitempty"` } // cleanCommandConfig is used for JSON output with omitempty to skip empty fields. @@ -90,14 +91,15 @@ func MarshalConfigJSON(cfg *Config) ([]byte, error) { // Filesystem config - only include if non-empty filesystem := cleanFilesystemConfig{ - DefaultDenyRead: cfg.Filesystem.DefaultDenyRead, - WSLInterop: cfg.Filesystem.WSLInterop, - AllowRead: cfg.Filesystem.AllowRead, - AllowExecute: cfg.Filesystem.AllowExecute, - DenyRead: cfg.Filesystem.DenyRead, - AllowWrite: cfg.Filesystem.AllowWrite, - DenyWrite: cfg.Filesystem.DenyWrite, - AllowGitConfig: cfg.Filesystem.AllowGitConfig, + DefaultDenyRead: cfg.Filesystem.DefaultDenyRead, + WSLInterop: cfg.Filesystem.WSLInterop, + ExtraReadableMounts: cfg.Filesystem.ExtraReadableMounts, + AllowRead: cfg.Filesystem.AllowRead, + AllowExecute: cfg.Filesystem.AllowExecute, + DenyRead: cfg.Filesystem.DenyRead, + AllowWrite: cfg.Filesystem.AllowWrite, + DenyWrite: cfg.Filesystem.DenyWrite, + AllowGitConfig: cfg.Filesystem.AllowGitConfig, } if !isFilesystemEmpty(filesystem) { clean.Filesystem = &filesystem @@ -143,6 +145,7 @@ func isNetworkEmpty(n cleanNetworkConfig) bool { func isFilesystemEmpty(f cleanFilesystemConfig) bool { return !f.DefaultDenyRead && f.WSLInterop == nil && + len(f.ExtraReadableMounts) == 0 && len(f.AllowRead) == 0 && len(f.AllowExecute) == 0 && len(f.DenyRead) == 0 && diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 09c907a..ebed9ee 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -59,6 +59,7 @@ func TestMarshalConfigJSON_IncludesExtendedFilesystemAndSSH(t *testing.T) { cfg := &Config{} cfg.Filesystem.DefaultDenyRead = true cfg.Filesystem.WSLInterop = &wslInterop + cfg.Filesystem.ExtraReadableMounts = []string{"/run", "/nix"} cfg.Filesystem.AllowRead = []string{"/workspace"} cfg.Filesystem.AllowExecute = []string{"/usr/bin/bash"} cfg.SSH.AllowedHosts = []string{"*.example.com"} @@ -71,6 +72,9 @@ func TestMarshalConfigJSON_IncludesExtendedFilesystemAndSSH(t *testing.T) { output := string(data) assert.Contains(t, output, `"defaultDenyRead": true`) assert.Contains(t, output, `"wslInterop": false`) + assert.Contains(t, output, `"extraReadableMounts": [`) + assert.Contains(t, output, `"/run"`) + assert.Contains(t, output, `"/nix"`) assert.Contains(t, output, `"allowRead": [`) assert.Contains(t, output, `"/workspace"`) assert.Contains(t, output, `"allowExecute": [`) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d03d871..d83cd7b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -111,6 +111,33 @@ func TestConfigValidate(t *testing.T) { }, wantErr: true, }, + { + name: "empty extraReadableMounts path", + config: Config{ + Filesystem: FilesystemConfig{ + ExtraReadableMounts: []string{""}, + }, + }, + wantErr: true, + }, + { + name: "relative extraReadableMounts path", + config: Config{ + Filesystem: FilesystemConfig{ + ExtraReadableMounts: []string{"run"}, + }, + }, + wantErr: true, + }, + { + name: "absolute extraReadableMounts path", + config: Config{ + Filesystem: FilesystemConfig{ + ExtraReadableMounts: []string{"/run"}, + }, + }, + wantErr: false, + }, { name: "empty allowRead path", config: Config{ @@ -547,14 +574,16 @@ func TestMerge(t *testing.T) { t.Run("merge filesystem config", func(t *testing.T) { base := &Config{ Filesystem: FilesystemConfig{ - AllowWrite: []string{"."}, - DenyRead: []string{"~/.ssh/**"}, + AllowWrite: []string{"."}, + DenyRead: []string{"~/.ssh/**"}, + ExtraReadableMounts: []string{"/nix"}, }, } override := &Config{ Filesystem: FilesystemConfig{ - AllowWrite: []string{"/tmp"}, - DenyWrite: []string{".env"}, + AllowWrite: []string{"/tmp"}, + DenyWrite: []string{".env"}, + ExtraReadableMounts: []string{"/run"}, }, } result := Merge(base, override) @@ -568,6 +597,9 @@ func TestMerge(t *testing.T) { if len(result.Filesystem.DenyWrite) != 1 { t.Errorf("expected 1 deny write path, got %d", len(result.Filesystem.DenyWrite)) } + if len(result.Filesystem.ExtraReadableMounts) != 2 { + t.Errorf("expected 2 extraReadableMounts paths, got %d", len(result.Filesystem.ExtraReadableMounts)) + } }) t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) { diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index 956094a..9e1d3c9 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -434,6 +434,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin } defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead + extraReadableMountPaths := getExtraReadableMountPaths(cfg, opts.Debug) if defaultDenyRead { // In defaultDenyRead mode, we only bind essential system paths read-only @@ -457,6 +458,14 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin // Track bound paths to avoid duplicate mounts across allowRead, allowExecute, and wslInterop boundPaths := make(map[string]bool) + // Bind additional Linux mount roots (and descendant submounts) explicitly. + for _, p := range extraReadableMountPaths { + if !boundPaths[p] { + boundPaths[p] = true + bwrapArgs = append(bwrapArgs, "--ro-bind", p, p) + } + } + // Bind user-specified allowRead paths if cfg != nil && cfg.Filesystem.AllowRead != nil { expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead) @@ -617,7 +626,9 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin crossMountWritable := make(map[string]bool) // Collect all cross-mount paths from allowExecute and allowRead - var crossMountPaths []string + // Keep explicit extra mount roots first so they cannot be shadowed by + // earlier child paths marking parent dirs as already bound. + crossMountPaths := append([]string(nil), extraReadableMountPaths...) for _, p := range cfg.Filesystem.AllowExecute { if !ContainsGlobChars(p) { crossMountPaths = append(crossMountPaths, NormalizePath(p)) diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go index 2b7fa9f..e06b455 100644 --- a/internal/sandbox/linux_landlock.go +++ b/internal/sandbox/linux_landlock.go @@ -151,6 +151,13 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin } } + // Extra Linux mount roots requested by config (plus descendant submounts). + for _, p := range getExtraReadableMountPaths(cfg, debug) { + if err := ruleset.AllowRead(p); err != nil && debug { + fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add extra readable mount path %s: %v\n", p, err) + } + } + // User-configured allowExecute paths if cfg != nil && cfg.Filesystem.AllowExecute != nil { expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowExecute) diff --git a/internal/sandbox/linux_mounts.go b/internal/sandbox/linux_mounts.go new file mode 100644 index 0000000..dd6086e --- /dev/null +++ b/internal/sandbox/linux_mounts.go @@ -0,0 +1,170 @@ +//go:build linux + +package sandbox + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/Use-Tusk/fence/internal/config" +) + +const mountInfoPath = "/proc/self/mountinfo" + +// getExtraReadableMountPaths returns Linux mount paths requested via +// filesystem.extraReadableMounts, expanded with descendant submounts from +// /proc/self/mountinfo and normalized for safe bind usage. +func getExtraReadableMountPaths(cfg *config.Config, debug bool) []string { + if cfg == nil || len(cfg.Filesystem.ExtraReadableMounts) == 0 { + return nil + } + + roots := normalizeExtraReadableMountRoots(cfg.Filesystem.ExtraReadableMounts) + if len(roots) == 0 { + return nil + } + + candidates := append([]string(nil), roots...) + mountPoints, err := readMountInfoMountPoints() + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "[fence:linux] Failed reading %s for extraReadableMounts (%v), using roots only\n", mountInfoPath, err) + } + } else { + candidates = expandRootsWithDescendantMounts(roots, mountPoints) + } + + seen := make(map[string]bool) + var out []string + for _, p := range candidates { + if isSpecialSandboxMountPath(p) { + continue + } + mountPath, ok := resolvePathForMount(p) + if !ok || isSpecialSandboxMountPath(mountPath) || seen[mountPath] { + continue + } + seen[mountPath] = true + out = append(out, mountPath) + } + return out +} + +func normalizeExtraReadableMountRoots(paths []string) []string { + seen := make(map[string]bool) + var out []string + for _, raw := range paths { + if raw == "" { + continue + } + if ContainsGlobChars(raw) { + continue + } + p := filepath.Clean(NormalizePath(raw)) + if p == "." || p == "" { + continue + } + if !strings.HasPrefix(p, "/") { + continue + } + if seen[p] { + continue + } + seen[p] = true + out = append(out, p) + } + slices.Sort(out) + return out +} + +func readMountInfoMountPoints() ([]string, error) { + f, err := os.Open(mountInfoPath) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + var mountPoints []string + for scanner.Scan() { + mountPoint, ok := parseMountInfoLineMountPoint(scanner.Text()) + if ok { + mountPoints = append(mountPoints, mountPoint) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return mountPoints, nil +} + +func parseMountInfoLineMountPoint(line string) (string, bool) { + parts := strings.SplitN(line, " - ", 2) + if len(parts) != 2 { + return "", false + } + pre := strings.Fields(parts[0]) + // mount point is field #5 in mountinfo, zero-indexed 4. + if len(pre) < 5 { + return "", false + } + return unescapeMountInfoPath(pre[4]), true +} + +func unescapeMountInfoPath(path string) string { + if !strings.Contains(path, "\\") { + return path + } + var b strings.Builder + for i := 0; i < len(path); i++ { + if path[i] == '\\' && i+3 < len(path) { + if n, err := strconv.ParseInt(path[i+1:i+4], 8, 32); err == nil { + b.WriteByte(byte(n)) + i += 3 + continue + } + } + b.WriteByte(path[i]) + } + return b.String() +} + +func expandRootsWithDescendantMounts(roots, mountPoints []string) []string { + seen := make(map[string]bool) + var out []string + for _, root := range roots { + cleanRoot := filepath.Clean(root) + if !seen[cleanRoot] { + seen[cleanRoot] = true + out = append(out, cleanRoot) + } + } + for _, mp := range mountPoints { + cleanMP := filepath.Clean(mp) + for _, root := range roots { + cleanRoot := filepath.Clean(root) + if cleanMP == cleanRoot || strings.HasPrefix(cleanMP, cleanRoot+"/") { + if !seen[cleanMP] { + seen[cleanMP] = true + out = append(out, cleanMP) + } + break + } + } + } + slices.Sort(out) + return out +} + +func isSpecialSandboxMountPath(path string) bool { + p := filepath.Clean(path) + return p == "/dev" || strings.HasPrefix(p, "/dev/") || + p == "/proc" || strings.HasPrefix(p, "/proc/") || + p == "/tmp" || strings.HasPrefix(p, "/tmp/") || + p == "/private/tmp" || strings.HasPrefix(p, "/private/tmp/") +} diff --git a/internal/sandbox/linux_test.go b/internal/sandbox/linux_test.go index e41200a..c927f7e 100644 --- a/internal/sandbox/linux_test.go +++ b/internal/sandbox/linux_test.go @@ -127,3 +127,49 @@ func TestWrapCommandLinuxWithOptions_DropsShellFromRuntimeDenyMounts(t *testing. t.Fatalf("shell path should not be masked in runtime deny mounts: %s", shellPath) } } + +func TestParseMountInfoLineMountPoint(t *testing.T) { + line := "2475 2474 0:406 / /run rw,nosuid,nodev - tmpfs tmpfs rw,size=1234k" + mountPoint, ok := parseMountInfoLineMountPoint(line) + if !ok { + t.Fatalf("expected mount point to parse") + } + if mountPoint != "/run" { + t.Fatalf("expected /run, got %q", mountPoint) + } +} + +func TestParseMountInfoLineMountPoint_UnescapesSpaces(t *testing.T) { + line := "275 1 0:45 / /mnt/with\\040space rw,relatime - ext4 /dev/vda rw" + mountPoint, ok := parseMountInfoLineMountPoint(line) + if !ok { + t.Fatalf("expected mount point to parse") + } + if mountPoint != "/mnt/with space" { + t.Fatalf("expected unescaped mount point, got %q", mountPoint) + } +} + +func TestExpandRootsWithDescendantMounts(t *testing.T) { + roots := []string{"/run", "/nix"} + mountPoints := []string{ + "/", + "/run", + "/run/user", + "/run/user/1000", + "/nix", + "/nix/store", + "/var", + } + + got := expandRootsWithDescendantMounts(roots, mountPoints) + want := []string{"/nix", "/nix/store", "/run", "/run/user", "/run/user/1000"} + if len(got) != len(want) { + t.Fatalf("expected %d paths, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected %q at index %d, got %q", want[i], i, got[i]) + } + } +}