Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions docs/schema/fence.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
},
"type": "array"
},
"extraReadableMounts": {
"items": {
"type": "string"
},
"type": "array"
},
"wslInterop": {
"type": [
"boolean",
Expand Down
24 changes: 24 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>`
- 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 <command>`) 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`.
Expand Down
36 changes: 23 additions & 13 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 19 additions & 16 deletions internal/config/config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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": [`)
Expand Down
40 changes: 36 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion internal/sandbox/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions internal/sandbox/linux_landlock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading