Skip to content
Merged
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
52 changes: 34 additions & 18 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,33 +300,43 @@ flowchart TB

subgraph Sandbox ["Sandbox (bwrap --unshare-net when available)"]
CMD["User Command"]
ISOCAT["socat :3128"]
ISOCKS["socat :1080"]
INIT["Fence linux-init<br/>(short-lived)"]
BRIDGE["Fence bridge helper<br/>:3128 / :1080"]
ENV2["HTTP_PROXY=127.0.0.1:3128"]
end

HTTP <--> HSOCAT
SOCKS <--> SSOCAT
HSOCAT <--> USOCK
SSOCAT <--> USOCK
USOCK <-->|bind-mounted| ISOCAT
USOCK <-->|bind-mounted| ISOCKS
CMD --> ISOCAT
CMD --> ISOCKS
INIT --> BRIDGE
INIT -->|exec| CMD
USOCK <-->|bind-mounted| BRIDGE
CMD --> BRIDGE
CMD -.-> ENV2
```

**Why `socat` bridges?**
**Why Unix-socket bridges?**

When `--unshare-net` is active, the sandbox cannot reach the host network at
all. Unix sockets provide filesystem-based IPC that works across namespace
boundaries:

1. Host `socat` connects a Unix socket to the host-side proxy
2. The Unix socket path is bind-mounted into the sandbox
3. Sandbox `socat` listens on `127.0.0.1` and forwards to the shared socket
3. The sandbox Fence bridge helper listens on `127.0.0.1` and forwards to the
shared socket
4. Traffic flows: `sandbox localhost -> Unix socket -> host proxy -> internet`

The staged Fence binary starts in a private `linux-init` mode. It validates a
versioned bootstrap plan, repairs runtime environment variables, starts one
bridge helper, waits for listener readiness, and then `exec()`s the security
shim and workload. Keeping `linux-init` short-lived preserves the workload's
process identity, signals, PTY behavior, and exit status. The bridge helper
watches the workload through a pidfd and exits when the workload does. On
kernels or restricted environments where `pidfd_open` is unavailable, it
falls back to polling for a parent-PID change.

Linux enforcement also layers in:

- Bubblewrap mount isolation for the base filesystem view
Expand All @@ -339,6 +349,8 @@ Linux enforcement also layers in:
- `path`: bind-mask selected executables
- `argv`: use a host-side Fence supervisor plus a sandbox-side shim that
installs a seccomp user-notification filter for `execve` / `execveat`
- Go-based in-sandbox bootstrap and relay listeners, independent of shell
utilities and sandbox-side `socat`
- Optional Landlock re-exec via the internal `--landlock-apply` wrapper
- Optional seccomp and eBPF monitoring

Expand All @@ -354,7 +366,11 @@ In `argv` mode, the Linux path adds a small helper pipeline:
If the environment does not support network namespaces (common in some
containers/CI setups), Fence can still configure proxies and filesystem policy,
but direct-network isolation becomes a best-effort proxy-oriented fallback
rather than a hard namespace boundary.
rather than a hard namespace boundary. In this shared-network mode (also used
for wildcard network policy), Fence skips the Unix-socket proxy bridge and
points proxy environment variables directly at the host proxy's random
loopback ports. The fixed sandbox facade ports 3128 and 1080 are used only
inside an isolated network namespace.

## Inbound Connections (Reverse Bridge)

Expand All @@ -371,14 +387,14 @@ flowchart TB
end

subgraph Sandbox
ISOCAT["socat<br/>UNIX-LISTEN"]
BRIDGE["Fence bridge helper<br/>UNIX-LISTEN"]
APP["App Server<br/>:8888"]
end

EXT --> HSOCAT
HSOCAT -->|UNIX-CONNECT| USOCK
USOCK <-->|shared via bind mount| ISOCAT
ISOCAT --> APP
USOCK <-->|shared via bind mount| BRIDGE
BRIDGE --> APP
```

Flow:
Expand All @@ -387,7 +403,7 @@ Flow:
`127.0.0.1`; configurable per port via the CLI's `-p ADDR:PORT` syntax
or the library's `ServiceOptions.Exposures`)
2. A shared Unix socket links host and sandbox
3. Sandbox `socat` forwards from the shared socket to the app
3. The sandbox Fence bridge helper forwards from the shared socket to the app
4. Traffic flows: `outside -> host port -> shared socket -> sandbox app`

If there is no isolated network namespace, a reverse bridge is unnecessary
Expand Down Expand Up @@ -420,12 +436,12 @@ flowchart TB
end

subgraph Sandbox ["Sandbox (bwrap --unshare-net)"]
ISOCAT["socat<br/>TCP-LISTEN 127.0.0.1:6379"]
BRIDGE["Fence bridge helper<br/>TCP-LISTEN 127.0.0.1:6379"]
APP["User Command"]
end

APP --> ISOCAT
ISOCAT -->|UNIX-CONNECT| USOCK
APP --> BRIDGE
BRIDGE -->|UNIX-CONNECT| USOCK
USOCK <-->|shared via bind mount| HSOCAT
HSOCAT --> SVC
```
Expand All @@ -436,8 +452,8 @@ up IPv6-only depending on DNS ordering) bind only the IPv6 loopback.

Flow (per address family):

1. Sandbox `socat` binds sandbox `127.0.0.1:<port>` (or `::1:<port>`) and
forwards to a shared Unix socket
1. The sandbox Fence bridge helper binds sandbox `127.0.0.1:<port>` (or
`::1:<port>`) and forwards to a shared Unix socket
2. Host `socat` listens on that Unix socket and forwards to host
`127.0.0.1:<port>` (or `[::1]:<port>`)
3. `NO_PROXY=localhost,127.0.0.1,::1` continues to direct clients straight at
Expand Down
28 changes: 0 additions & 28 deletions cmd/fence/linux_helpers_linux.go

This file was deleted.

21 changes: 0 additions & 21 deletions cmd/fence/linux_helpers_nonlinux.go

This file was deleted.

115 changes: 15 additions & 100 deletions cmd/fence/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main

import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net"
Expand Down Expand Up @@ -60,14 +59,12 @@ func main() {
}
}

// Check for internal helper modes used inside/generated by the sandbox.
// This must be checked before cobra to avoid flag conflicts
if len(os.Args) >= 2 && os.Args[1] == "--landlock-apply" {
runLandlockWrapper()
return
}
if runLinuxInternalHelperMode(os.Args) {
return
// Internal helper modes must run before cobra to avoid flag conflicts.
if handled, helperExitCode, err := sandbox.DispatchInternalHelper(os.Args); handled {
if err != nil {
fencelog.Printf("[fence:helper] %v\n", err)
}
os.Exit(helperExitCode)
}
if len(os.Args) >= 2 && os.Args[1] == claudePreToolUseMode {
if err := runClaudePreToolUseMode(); err != nil {
Expand Down Expand Up @@ -286,6 +283,15 @@ func runCommand(cmd *cobra.Command, args []string) error {
cfg = applyCLIConfigOverrides(cmd, cfg, forceNewSession)

manager := sandbox.NewManager(cfg, debug, monitor)
if platform.Detect() == platform.Linux {
helperPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to locate Fence Linux helper: %w", err)
}
if err := manager.SetLinuxHelperPath(helperPath); err != nil {
return fmt.Errorf("failed to configure Fence Linux helper: %w", err)
}
}
manager.SetService(sandbox.ServiceOptions{
Exposures: exposures,
ExecutionModel: execModel,
Expand Down Expand Up @@ -831,94 +837,3 @@ func printTemplates() {
fmt.Println("Usage: fence -t <template> <command>")
fmt.Println("Example: fence -t code -- code")
}

// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
// It applies Landlock restrictions and then execs the user command.
// Usage: fence --landlock-apply [--debug] -- <command...>
// Config is passed via FENCE_CONFIG_JSON environment variable.
func runLandlockWrapper() {
// Parse arguments: --landlock-apply [--debug] -- <command...>
args := os.Args[2:] // Skip "fence" and "--landlock-apply"

var debugMode bool
var cmdStart int

for i := 0; i < len(args); i++ {
switch args[i] {
case "--debug":
debugMode = true
case "--":
cmdStart = i + 1
goto parseCommand
default:
// Assume rest is the command
cmdStart = i
goto parseCommand
}
}

parseCommand:
if cmdStart >= len(args) {
fencelog.Printf("[fence:landlock-wrapper] Error: no command specified\n")
os.Exit(1)
}

command := args[cmdStart:]

if debugMode {
fencelog.Printf("[fence:landlock-wrapper] Applying Landlock restrictions\n")
}

// Only apply Landlock on Linux
if platform.Detect() == platform.Linux {
// Load config from environment variable (passed by parent fence process)
var cfg *config.Config
if configJSON := os.Getenv("FENCE_CONFIG_JSON"); configJSON != "" {
cfg = &config.Config{}
if err := json.Unmarshal([]byte(configJSON), cfg); err != nil {
if debugMode {
fencelog.Printf("[fence:landlock-wrapper] Warning: failed to parse config: %v\n", err)
}
cfg = nil
}
}
if cfg == nil {
cfg = config.Default()
}

// Get current working directory for relative path resolution
cwd, _ := os.Getwd()

// Apply Landlock restrictions
err := sandbox.ApplyLandlockFromConfig(cfg, cwd, nil, debugMode)
if err != nil {
if debugMode {
fencelog.Printf("[fence:landlock-wrapper] Warning: Landlock not applied: %v\n", err)
}
// Continue without Landlock - bwrap still provides isolation
} else if debugMode {
fencelog.Printf("[fence:landlock-wrapper] Landlock restrictions applied\n")
}
}

// Find the executable
execPath, err := exec.LookPath(command[0])
if err != nil {
fencelog.Printf("[fence:landlock-wrapper] Error: command not found: %s\n", command[0])
os.Exit(127)
}

if debugMode {
fencelog.Printf("[fence:landlock-wrapper] Exec: %s %v\n", execPath, command[1:])
}

// Sanitize environment (strips LD_PRELOAD, etc.)
hardenedEnv := sandbox.FilterDangerousEnv(os.Environ())

// Exec the command (replaces this process)
err = syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec
if err != nil {
fencelog.Printf("[fence:landlock-wrapper] Exec failed: %v\n", err)
os.Exit(1)
}
}
Loading
Loading