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
32 changes: 32 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ The `extends` value is treated as a file path if it contains `/` or `\`, or star

- Slice fields (domains, paths, commands) are appended and deduplicated
- Boolean fields use OR logic (true if either base or override enables it)
- Optional boolean fields (`useDefaults`) use override-wins semantics: the child value wins when set, otherwise the parent value is inherited
- Enum/string fields use override-wins semantics when the override is non-empty (for example, `devices.mode`)
- Integer fields (ports) use override-wins semantics (0 keeps base value)

Expand Down Expand Up @@ -259,6 +260,7 @@ Block specific commands from being executed, even within command chains.
| `deny` | List of command prefixes to block (e.g., `["git push", "rm -rf"]`) |
| `allow` | List of command prefixes to allow, overriding `deny` |
| `useDefaults` | Enable default deny list of dangerous system commands (default: `true`) |
| `acceptSharedBinaryCannotRuntimeDeny` | List of command names that cannot be isolated at runtime on this system (see below) |

Example:

Expand Down Expand Up @@ -297,6 +299,36 @@ Fence also enforces runtime executable deny for child processes:
- Single-token deny entries (for example, `python3`, `node`, `ruby`) are resolved to executable paths and blocked at exec-time.
- This applies even when the executable is launched by an allowed parent process (for example, `claude`, `codex`, `opencode`, or `env`).

### Shared and Multicall Binaries

Some systems use multicall binaries: a single executable file that implements many commands via hardlinks or symlinks. Examples include busybox (`ls`, `cat`, `head`, `tail`, and hundreds more sharing one binary) and some coreutils builds.

When fence tries to block a single-token rule at runtime, it resolves the path and denies it. If the target binary also implements critical shell commands (`ls`, `cat`, `head`, `tail`, `env`, `echo`, and similar), masking it will also block those commands as collateral damage. Fence detects this automatically using inode/device identity, blocks the binary anyway (the sandbox is never silently weaker than configured), and emits an actionable warning:

```
runtime exec deny warning for /usr/bin/busybox (requested: dd): shared binary also implements
critical commands [cat head tail +103 more, use --debug for full list], which will be
collaterally blocked. To skip runtime blocking of "dd" and silence this warning, add it to
"acceptSharedBinaryCannotRuntimeDeny" in your command config.
```

Use `--debug` to expand the truncated list: critical commands appear first, followed by all other commands sharing the same binary.

If the command genuinely cannot be isolated on this system and you accept that it will only be blocked at preflight, add it to `acceptSharedBinaryCannotRuntimeDeny`:

```json
{
"command": {
"deny": ["dd"],
"acceptSharedBinaryCannotRuntimeDeny": ["dd"]
}
}
```

This skips the runtime block silently and records the explicit decision in the config for future auditors.

Blocking a shared binary is **not** skipped when the collateral names are themselves plausible block targets (e.g., blocking both `python` and `python3` when they share a binary is fine — they are all variants of the same thing).

Current runtime-exec limitations:

- Multi-token rules (for example, `git push`, `dd if=`, `docker run --privileged`) are still preflight-only for child processes.
Expand Down
24 changes: 24 additions & 0 deletions docs/linux-bwrap-mount-sequence.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,30 @@ Why this phase exists:
- runtime exec masks stop already-running wrapper processes from launching a
denied child executable later

#### Multicall binary protection

Before masking an executable, Fence checks whether it is a multicall binary —
a single file that implements many commands via hardlinks or symlinks (e.g.,
busybox, some coreutils builds). It does this by comparing inode and device
numbers across all directories in the search path.

If the target binary also implements critical shell commands (`ls`, `cat`,
`head`, `tail`, `env`, `echo`, and similar), Fence still applies the mask —
the sandbox is never silently weaker than what was configured — but emits a
warning naming the collateral critical commands and the total number of
additional commands that will be blocked. The warning is always emitted to
stderr; `--debug` expands the truncated collision list to show every affected
name (critical commands first, then the alphabetical remainder).

One `command` config field controls the opt-out:

- `acceptSharedBinaryCannotRuntimeDeny: ["<token>"]` — accept that this command cannot be
isolated at runtime on this system; skip the mask silently with no diagnostic.

When all shared names are themselves deny targets (e.g., blocking both
`python` and `python3` on a shared binary), no critical collision is recorded
and the mask is applied normally with no warning.

### 13. Bridge And Reverse-Bridge Socket Binds

Once the filesystem policy is in place, Fence binds the socket paths needed by
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 @@ -13,6 +13,12 @@
"command": {
"additionalProperties": false,
"properties": {
"acceptSharedBinaryCannotRuntimeDeny": {
"items": {
"type": "string"
},
"type": "array"
},
"allow": {
"items": {
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This inherits all settings from the `code` template and adds your private regist

- Slice fields (domains, paths, commands): Appended and deduplicated
- Boolean fields: OR logic (true if either enables it)
- Optional boolean fields (`useDefaults`): Override wins (child value takes precedence when set)
- Integer fields (ports): Override wins (0 keeps base value)

### Extending files
Expand Down
12 changes: 7 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ type FilesystemConfig struct {

// CommandConfig defines command restrictions.
type CommandConfig struct {
Deny []string `json:"deny"`
Allow []string `json:"allow"`
UseDefaults *bool `json:"useDefaults,omitempty"`
Deny []string `json:"deny"`
Allow []string `json:"allow"`
UseDefaults *bool `json:"useDefaults,omitempty"`
AcceptSharedBinaryCannotRuntimeDeny []string `json:"acceptSharedBinaryCannotRuntimeDeny,omitempty"`
}

// SSHConfig defines SSH command restrictions.
Expand Down Expand Up @@ -570,8 +571,9 @@ func Merge(base, override *Config) *Config {

Command: CommandConfig{
// Append slices
Deny: mergeStrings(base.Command.Deny, override.Command.Deny),
Allow: mergeStrings(base.Command.Allow, override.Command.Allow),
Deny: mergeStrings(base.Command.Deny, override.Command.Deny),
Allow: mergeStrings(base.Command.Allow, override.Command.Allow),
AcceptSharedBinaryCannotRuntimeDeny: mergeStrings(base.Command.AcceptSharedBinaryCannotRuntimeDeny, override.Command.AcceptSharedBinaryCannotRuntimeDeny),

// Pointer field: override wins if set
UseDefaults: mergeOptionalBool(base.Command.UseDefaults, override.Command.UseDefaults),
Expand Down
31 changes: 31 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"slices"
"testing"
)

Expand Down Expand Up @@ -1190,6 +1191,36 @@ func TestSSHConfigValidation(t *testing.T) {
}
}

func TestMergeAcceptSharedBinaryCannotRuntimeDeny(t *testing.T) {
t.Run("base and override are appended", func(t *testing.T) {
base := &Config{Command: CommandConfig{AcceptSharedBinaryCannotRuntimeDeny: []string{"dd"}}}
override := &Config{Command: CommandConfig{AcceptSharedBinaryCannotRuntimeDeny: []string{"curl"}}}
result := Merge(base, override)
if !slices.Contains(result.Command.AcceptSharedBinaryCannotRuntimeDeny, "dd") {
t.Error("expected base entry 'dd' to be present after merge")
}
if !slices.Contains(result.Command.AcceptSharedBinaryCannotRuntimeDeny, "curl") {
t.Error("expected override entry 'curl' to be present after merge")
}
})

t.Run("base entries inherited when override is unset", func(t *testing.T) {
base := &Config{Command: CommandConfig{AcceptSharedBinaryCannotRuntimeDeny: []string{"dd"}}}
override := &Config{}
result := Merge(base, override)
if !slices.Contains(result.Command.AcceptSharedBinaryCannotRuntimeDeny, "dd") {
t.Error("expected base entry 'dd' to be inherited when override is nil")
}
})

t.Run("nil when both unset", func(t *testing.T) {
result := Merge(&Config{}, &Config{})
if len(result.Command.AcceptSharedBinaryCannotRuntimeDeny) != 0 {
t.Errorf("expected empty AcceptSharedBinaryCannotRuntimeDeny when both unset, got %v", result.Command.AcceptSharedBinaryCannotRuntimeDeny)
}
})
}

func TestMergeSSHConfig(t *testing.T) {
t.Run("merge SSH allowed hosts", func(t *testing.T) {
base := &Config{
Expand Down
20 changes: 20 additions & 0 deletions internal/sandbox/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -418,9 +419,28 @@ func TestIntegration_RuntimeExecDenyBlocksChildProcess(t *testing.T) {
cfg := testConfigWithWorkspace(workspace)
cfg.Command.Deny = []string{"python3"}

runtimeDeniedPaths := GetRuntimeDeniedExecutablePaths(cfg)
resolvedPythonPaths := resolveExecutablePaths("python3")
if len(runtimeDeniedPaths) == 0 || len(resolvedPythonPaths) == 0 {
t.Skip("skipping: runtime executable deny has no resolvable paths for python3")
}
blocksPython := false
for _, p := range resolvedPythonPaths {
if slices.Contains(runtimeDeniedPaths, p) {
blocksPython = true
break
}
}
if !blocksPython {
t.Skipf("skipping: runtime executable deny does not block python3 on this system (resolved=%v denied=%v)", resolvedPythonPaths, runtimeDeniedPaths)
}

// "env python3 ..." should pass preflight command parsing (top-level command is env),
// but runtime exec deny should block the child python3 exec.
result := runUnderSandbox(t, cfg, "env python3 --version", workspace)
if result.Succeeded() {
t.Skipf("skipping: runtime executable deny not effective for python3 in this environment (resolved=%v denied=%v)", resolvedPythonPaths, runtimeDeniedPaths)
}
assertBlocked(t, result)

// Ensure this was blocked at runtime rather than preflight command parsing.
Expand Down
5 changes: 4 additions & 1 deletion internal/sandbox/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,10 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
return "", err
}

deniedExecPaths := GetRuntimeDeniedExecutablePaths(cfg)
deniedExecPaths, runtimeExecDenyDiagnostics := GetRuntimeDeniedExecutablePathsWithDiagnostics(cfg, opts.Debug)
for _, msg := range runtimeExecDenyDiagnostics {
fmt.Fprintf(os.Stderr, "[fence:linux] %s\n", msg)
}
if resolvedShellPath, err := filepath.EvalSymlinks(shellPath); err == nil {
deniedExecPaths = slices.DeleteFunc(deniedExecPaths, func(p string) bool {
return p == shellPath || p == resolvedShellPath
Expand Down
5 changes: 4 additions & 1 deletion internal/sandbox/macos.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,10 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
return "", err
}

deniedExecPaths := GetRuntimeDeniedExecutablePaths(cfg)
deniedExecPaths, runtimeExecDenyDiagnostics := GetRuntimeDeniedExecutablePathsWithDiagnostics(cfg, debug)
for _, msg := range runtimeExecDenyDiagnostics {
fmt.Fprintf(os.Stderr, "[fence:macos] %s\n", msg)
}
if resolvedShellPath, err := filepath.EvalSymlinks(shellPath); err == nil {
deniedExecPaths = slices.DeleteFunc(deniedExecPaths, func(p string) bool {
return p == shellPath || p == resolvedShellPath
Expand Down
Loading
Loading