diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2c84c46..605370a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,23 +1,28 @@
# Architecture
-Fence restricts network and filesystem access for arbitrary commands. It works by:
+Fence restricts network, filesystem, and command access for arbitrary commands. It works by:
-1. **Intercepting network traffic** via HTTP/SOCKS5 proxies that filter by domain
-2. **Sandboxing processes** using OS-native mechanisms (macOS sandbox-exec, Linux bubblewrap)
-3. **Bridging connections** to allow controlled inbound/outbound traffic in isolated namespaces
+1. **Blocking commands** via configurable deny/allow lists before execution
+2. **Intercepting network traffic** via HTTP/SOCKS5 proxies that filter by domain
+3. **Sandboxing processes** using OS-native mechanisms (macOS sandbox-exec, Linux bubblewrap)
+4. **Sanitizing environment** by stripping dangerous variables (LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.)
```mermaid
flowchart TB
subgraph Fence
Config["Config
(JSON)"]
Manager
+ CmdCheck["Command
Blocking"]
+ EnvSanitize["Env
Sanitization"]
Sandbox["Platform Sandbox
(macOS/Linux)"]
HTTP["HTTP Proxy
(filtering)"]
SOCKS["SOCKS5 Proxy
(filtering)"]
end
Config --> Manager
- Manager --> Sandbox
+ Manager --> CmdCheck
+ CmdCheck --> EnvSanitize
+ EnvSanitize --> Sandbox
Manager --> HTTP
Manager --> SOCKS
```
@@ -42,6 +47,8 @@ fence/
│ ├── linux_features.go # Kernel feature detection
│ ├── linux_*_stub.go # Non-Linux build stubs
│ ├── monitor.go # macOS log stream violation monitoring
+│ ├── command.go # Command blocking/allow lists
+│ ├── hardening.go # Environment sanitization
│ ├── dangerous.go # Protected file/directory lists
│ ├── shell.go # Shell quoting utilities
│ └── utils.go # Path normalization
@@ -59,12 +66,13 @@ Handles loading and validating sandbox configuration:
type Config struct {
Network NetworkConfig // Domain allow/deny lists
Filesystem FilesystemConfig // Read/write restrictions
+ Command CommandConfig // Command deny/allow lists
AllowPty bool // Allow pseudo-terminal allocation
}
```
- Loads from `~/.fence.json` or custom path
-- Falls back to restrictive defaults (block all network)
+- Falls back to restrictive defaults (block all network, default command deny list)
- Validates paths and normalizes them
### Platform (`internal/platform/`)
@@ -108,8 +116,27 @@ Orchestrates the sandbox lifecycle:
1. Initializes HTTP and SOCKS proxies
2. Sets up platform-specific bridges (Linux)
-3. Wraps commands with sandbox restrictions
-4. Handles cleanup on exit
+3. Checks command against deny/allow lists
+4. Wraps commands with sandbox restrictions
+5. Handles cleanup on exit
+
+#### Command Blocking (`command.go`)
+
+Blocks commands before they run based on configurable policies:
+
+- **Default deny list**: Dangerous system commands (`shutdown`, `reboot`, `mkfs`, `rm -rf`, etc.)
+- **Custom deny/allow**: User-configured prefixes (e.g., `git push`, `npm publish`)
+- **Chain detection**: Parses `&&`, `||`, `;`, `|` to catch blocked commands in pipelines
+- **Nested shells**: Detects `bash -c "blocked_cmd"` patterns
+
+#### Environment Sanitization (`hardening.go`)
+
+Strips dangerous environment variables before command execution:
+
+- Linux: `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, etc.
+- macOS: `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, etc.
+
+This prevents library injection attacks where a sandboxed process writes a malicious `.so`/`.dylib` and uses `LD_PRELOAD`/`DYLD_INSERT_LIBRARIES` in a subsequent command.
#### macOS Implementation (`macos.go`)
@@ -229,15 +256,18 @@ flowchart TD
D1 & D2 & D3 & D4 --> E["5. Manager.WrapCommand()"]
- E --> E1["[macOS] Generate Seatbelt profile"]
- E --> E2["[Linux] Generate bwrap command"]
+ E --> E0{"Check command
deny/allow lists"}
+ E0 -->|blocked| ERR["Return error"]
+ E0 -->|allowed| E1["[macOS] Generate Seatbelt profile"]
+ E0 -->|allowed| E2["[Linux] Generate bwrap command"]
- E1 & E2 --> F["6. Execute wrapped command"]
- F --> G["7. Manager.Cleanup()"]
+ E1 & E2 --> F["6. Sanitize env
(strip LD_*/DYLD_*)"]
+ F --> G["7. Execute wrapped command"]
+ G --> H["8. Manager.Cleanup()"]
- G --> G1["Kill socat processes"]
- G --> G2["Remove Unix sockets"]
- G --> G3["Stop proxy servers"]
+ H --> H1["Kill socat processes"]
+ H --> H2["Remove Unix sockets"]
+ H --> H3["Stop proxy servers"]
```
## Platform Comparison
@@ -251,6 +281,7 @@ flowchart TD
| Syscall filtering | Implicit (Seatbelt) | seccomp BPF |
| Inbound connections | Profile rules (`network-bind`) | Reverse socat bridges |
| Violation monitoring | log stream + proxy | eBPF + proxy |
+| Env sanitization | Strips DYLD_* | Strips LD_* |
| Requirements | Built-in | bwrap, socat |
### Linux Security Layers
@@ -269,7 +300,7 @@ See [Linux Security Features](./docs/linux-security-features.md) for details.
## Violation Monitoring
-The `-m` (monitor) flag enables real-time visibility into blocked operations.
+The `-m` (monitor) flag enables real-time visibility into blocked operations. These only apply to filesystem and network operations, not blocked commands.
### Output Prefixes
diff --git a/cmd/fence/main.go b/cmd/fence/main.go
index 2dfe007..2b68a95 100644
--- a/cmd/fence/main.go
+++ b/cmd/fence/main.go
@@ -185,7 +185,15 @@ func runCommand(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "[fence] Sandboxed command: %s\n", sandboxedCommand)
}
+ hardenedEnv := sandbox.GetHardenedEnv()
+ if debug {
+ if stripped := sandbox.GetStrippedEnvVars(os.Environ()); len(stripped) > 0 {
+ fmt.Fprintf(os.Stderr, "[fence] Stripped dangerous env vars: %v\n", stripped)
+ }
+ }
+
execCmd := exec.Command("sh", "-c", sandboxedCommand) //nolint:gosec // sandboxedCommand is constructed from user input - intentional
+ execCmd.Env = hardenedEnv
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
@@ -318,8 +326,11 @@ parseCommand:
fmt.Fprintf(os.Stderr, "[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, os.Environ()) //nolint:gosec
+ err = syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec
if err != nil {
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec failed: %v\n", err)
os.Exit(1)
diff --git a/docs/security-model.md b/docs/security-model.md
index 7858a67..a74c3ec 100644
--- a/docs/security-model.md
+++ b/docs/security-model.md
@@ -43,6 +43,15 @@ Localhost is separate from "external domains":
- **denyRead** can block reads from sensitive paths.
- Fence includes an internal list of always-protected targets (e.g. shell configs, git hooks) to reduce common persistence vectors.
+### Environment sanitization
+
+Fence strips dangerous environment variables before passing them to sandboxed commands:
+
+- `LD_*` (Linux): `LD_PRELOAD`, `LD_LIBRARY_PATH`, etc.
+- `DYLD_*` (macOS): `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, etc.
+
+This prevents a library injection attack where a sandboxed process writes a malicious `.so`/`.dylib` and then uses `LD_PRELOAD`/`DYLD_INSERT_LIBRARIES` in a subsequent command to load it.
+
## Visibility / auditing
- `-m/--monitor` helps you discover what a command *tries* to access (blocked only).
diff --git a/internal/sandbox/command.go b/internal/sandbox/command.go
index f077a0e..440e788 100644
--- a/internal/sandbox/command.go
+++ b/internal/sandbox/command.go
@@ -1,4 +1,3 @@
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
@@ -18,9 +17,9 @@ type CommandBlockedError struct {
func (e *CommandBlockedError) Error() string {
if e.IsDefault {
- return fmt.Sprintf("command blocked by default policy: %q matches %q", e.Command, e.BlockedPrefix)
+ return fmt.Sprintf("command blocked by default sandbox command policy: %q matches %q", e.Command, e.BlockedPrefix)
}
- return fmt.Sprintf("command blocked by policy: %q matches %q", e.Command, e.BlockedPrefix)
+ return fmt.Sprintf("command blocked by sandbox command policy: %q matches %q", e.Command, e.BlockedPrefix)
}
// CheckCommand checks if a command is allowed by the configuration.
diff --git a/internal/sandbox/dangerous.go b/internal/sandbox/dangerous.go
index 2a7d1a1..bd35bbc 100644
--- a/internal/sandbox/dangerous.go
+++ b/internal/sandbox/dangerous.go
@@ -1,4 +1,3 @@
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
diff --git a/internal/sandbox/linux_ebpf.go b/internal/sandbox/linux_ebpf.go
index 73ffff2..ce4ea4a 100644
--- a/internal/sandbox/linux_ebpf.go
+++ b/internal/sandbox/linux_ebpf.go
@@ -1,6 +1,5 @@
//go:build linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
diff --git a/internal/sandbox/linux_ebpf_stub.go b/internal/sandbox/linux_ebpf_stub.go
index 9de847e..cd5aa8d 100644
--- a/internal/sandbox/linux_ebpf_stub.go
+++ b/internal/sandbox/linux_ebpf_stub.go
@@ -1,6 +1,5 @@
//go:build !linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import "time"
diff --git a/internal/sandbox/linux_features.go b/internal/sandbox/linux_features.go
index da3ed5e..290c476 100644
--- a/internal/sandbox/linux_features.go
+++ b/internal/sandbox/linux_features.go
@@ -1,6 +1,5 @@
//go:build linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
diff --git a/internal/sandbox/linux_features_stub.go b/internal/sandbox/linux_features_stub.go
index 850b451..3db8224 100644
--- a/internal/sandbox/linux_features_stub.go
+++ b/internal/sandbox/linux_features_stub.go
@@ -1,6 +1,5 @@
//go:build !linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
// LinuxFeatures describes available Linux sandboxing features.
diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go
index 8706bf0..4ab71ed 100644
--- a/internal/sandbox/linux_landlock.go
+++ b/internal/sandbox/linux_landlock.go
@@ -1,6 +1,5 @@
//go:build linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
diff --git a/internal/sandbox/linux_landlock_stub.go b/internal/sandbox/linux_landlock_stub.go
index 38e64f4..57166d4 100644
--- a/internal/sandbox/linux_landlock_stub.go
+++ b/internal/sandbox/linux_landlock_stub.go
@@ -1,6 +1,5 @@
//go:build !linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import "github.com/Use-Tusk/fence/internal/config"
diff --git a/internal/sandbox/linux_seccomp.go b/internal/sandbox/linux_seccomp.go
index 665eb89..ce28016 100644
--- a/internal/sandbox/linux_seccomp.go
+++ b/internal/sandbox/linux_seccomp.go
@@ -1,6 +1,5 @@
//go:build linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
diff --git a/internal/sandbox/linux_seccomp_stub.go b/internal/sandbox/linux_seccomp_stub.go
index 953c557..b878c36 100644
--- a/internal/sandbox/linux_seccomp_stub.go
+++ b/internal/sandbox/linux_seccomp_stub.go
@@ -1,6 +1,5 @@
//go:build !linux
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
// SeccompFilter is a stub for non-Linux platforms.
diff --git a/internal/sandbox/monitor.go b/internal/sandbox/monitor.go
index 2dcb4d9..6484d5d 100644
--- a/internal/sandbox/monitor.go
+++ b/internal/sandbox/monitor.go
@@ -1,4 +1,3 @@
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
diff --git a/internal/sandbox/sanitize.go b/internal/sandbox/sanitize.go
new file mode 100644
index 0000000..b314c38
--- /dev/null
+++ b/internal/sandbox/sanitize.go
@@ -0,0 +1,114 @@
+package sandbox
+
+import (
+ "os"
+ "runtime"
+ "strings"
+)
+
+// DangerousEnvPrefixes lists environment variable prefixes that can be used
+// to subvert library loading and should be stripped from sandboxed processes.
+//
+// - LD_* (Linux): LD_PRELOAD, LD_LIBRARY_PATH can inject malicious shared libraries
+// - DYLD_* (macOS): DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH can inject dylibs
+var DangerousEnvPrefixes = []string{
+ "LD_", // Linux dynamic linker
+ "DYLD_", // macOS dynamic linker
+}
+
+// DangerousEnvVars lists specific environment variables that should be stripped.
+var DangerousEnvVars = []string{
+ "LD_PRELOAD",
+ "LD_LIBRARY_PATH",
+ "LD_AUDIT",
+ "LD_DEBUG",
+ "LD_DEBUG_OUTPUT",
+ "LD_DYNAMIC_WEAK",
+ "LD_ORIGIN_PATH",
+ "LD_PROFILE",
+ "LD_PROFILE_OUTPUT",
+ "LD_SHOW_AUXV",
+ "LD_TRACE_LOADED_OBJECTS",
+ "DYLD_INSERT_LIBRARIES",
+ "DYLD_LIBRARY_PATH",
+ "DYLD_FRAMEWORK_PATH",
+ "DYLD_FALLBACK_LIBRARY_PATH",
+ "DYLD_FALLBACK_FRAMEWORK_PATH",
+ "DYLD_IMAGE_SUFFIX",
+ "DYLD_FORCE_FLAT_NAMESPACE",
+ "DYLD_PRINT_LIBRARIES",
+ "DYLD_PRINT_APIS",
+}
+
+// GetHardenedEnv returns a copy of the current environment with dangerous
+// variables removed. This prevents library injection attacks where a malicious
+// agent writes a .so/.dylib and then uses LD_PRELOAD/DYLD_INSERT_LIBRARIES
+// in a subsequent command.
+func GetHardenedEnv() []string {
+ return FilterDangerousEnv(os.Environ())
+}
+
+// FilterDangerousEnv filters out dangerous environment variables from the given slice.
+func FilterDangerousEnv(env []string) []string {
+ filtered := make([]string, 0, len(env))
+ for _, e := range env {
+ if !isDangerousEnvVar(e) {
+ filtered = append(filtered, e)
+ }
+ }
+ return filtered
+}
+
+// isDangerousEnvVar checks if an environment variable entry (KEY=VALUE) is dangerous.
+func isDangerousEnvVar(entry string) bool {
+ // Split on first '=' to get the key
+ key := entry
+ if idx := strings.Index(entry, "="); idx != -1 {
+ key = entry[:idx]
+ }
+
+ // Check against known dangerous prefixes
+ for _, prefix := range DangerousEnvPrefixes {
+ if strings.HasPrefix(key, prefix) {
+ return true
+ }
+ }
+
+ // Check against specific dangerous vars
+ for _, dangerous := range DangerousEnvVars {
+ if key == dangerous {
+ return true
+ }
+ }
+
+ return false
+}
+
+// GetStrippedEnvVars returns a list of environment variable names that were
+// stripped from the given environment. Useful for debug logging.
+func GetStrippedEnvVars(env []string) []string {
+ var stripped []string
+ for _, e := range env {
+ if isDangerousEnvVar(e) {
+ // Extract just the key
+ if idx := strings.Index(e, "="); idx != -1 {
+ stripped = append(stripped, e[:idx])
+ } else {
+ stripped = append(stripped, e)
+ }
+ }
+ }
+ return stripped
+}
+
+// HardeningFeatures returns a description of environment sanitization applied on this platform.
+func HardeningFeatures() string {
+ switch runtime.GOOS {
+ case "linux":
+ return "env-filter(LD_*)"
+ case "darwin":
+ return "env-filter(DYLD_*)"
+ default:
+ return "env-filter"
+ }
+}
diff --git a/internal/sandbox/sanitize_test.go b/internal/sandbox/sanitize_test.go
new file mode 100644
index 0000000..3d27834
--- /dev/null
+++ b/internal/sandbox/sanitize_test.go
@@ -0,0 +1,156 @@
+package sandbox
+
+import (
+ "testing"
+)
+
+func TestIsDangerousEnvVar(t *testing.T) {
+ tests := []struct {
+ entry string
+ dangerous bool
+ }{
+ // Linux LD_* variables
+ {"LD_PRELOAD=/tmp/evil.so", true},
+ {"LD_LIBRARY_PATH=/tmp", true},
+ {"LD_AUDIT=/tmp/audit.so", true},
+ {"LD_DEBUG=all", true},
+
+ // macOS DYLD_* variables
+ {"DYLD_INSERT_LIBRARIES=/tmp/evil.dylib", true},
+ {"DYLD_LIBRARY_PATH=/tmp", true},
+ {"DYLD_FRAMEWORK_PATH=/tmp", true},
+ {"DYLD_FORCE_FLAT_NAMESPACE=1", true},
+
+ // Safe variables
+ {"PATH=/usr/bin:/bin", false},
+ {"HOME=/home/user", false},
+ {"USER=user", false},
+ {"SHELL=/bin/bash", false},
+ {"HTTP_PROXY=http://localhost:8080", false},
+ {"HTTPS_PROXY=http://localhost:8080", false},
+
+ // Edge cases - variables that start with similar prefixes but aren't dangerous
+ {"LDFLAGS=-L/usr/lib", false}, // Not LD_ prefix
+ {"DISPLAY=:0", false},
+
+ // Empty and malformed
+ {"LD_PRELOAD", true}, // No value but still dangerous
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.entry, func(t *testing.T) {
+ got := isDangerousEnvVar(tt.entry)
+ if got != tt.dangerous {
+ t.Errorf("isDangerousEnvVar(%q) = %v, want %v", tt.entry, got, tt.dangerous)
+ }
+ })
+ }
+}
+
+func TestFilterDangerousEnv(t *testing.T) {
+ env := []string{
+ "PATH=/usr/bin:/bin",
+ "LD_PRELOAD=/tmp/evil.so",
+ "HOME=/home/user",
+ "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib",
+ "HTTP_PROXY=http://localhost:8080",
+ "LD_LIBRARY_PATH=/tmp",
+ }
+
+ filtered := FilterDangerousEnv(env)
+
+ // Should have 3 safe vars
+ if len(filtered) != 3 {
+ t.Errorf("expected 3 safe vars, got %d: %v", len(filtered), filtered)
+ }
+
+ // Verify the safe vars are present
+ expected := map[string]bool{
+ "PATH=/usr/bin:/bin": true,
+ "HOME=/home/user": true,
+ "HTTP_PROXY=http://localhost:8080": true,
+ }
+
+ for _, e := range filtered {
+ if !expected[e] {
+ t.Errorf("unexpected var in filtered env: %s", e)
+ }
+ }
+
+ // Verify dangerous vars are gone
+ for _, e := range filtered {
+ if isDangerousEnvVar(e) {
+ t.Errorf("dangerous var not filtered: %s", e)
+ }
+ }
+}
+
+func TestGetStrippedEnvVars(t *testing.T) {
+ env := []string{
+ "PATH=/usr/bin",
+ "LD_PRELOAD=/tmp/evil.so",
+ "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib",
+ "HOME=/home/user",
+ }
+
+ stripped := GetStrippedEnvVars(env)
+
+ if len(stripped) != 2 {
+ t.Errorf("expected 2 stripped vars, got %d: %v", len(stripped), stripped)
+ }
+
+ // Should contain just the keys, not values
+ found := make(map[string]bool)
+ for _, s := range stripped {
+ found[s] = true
+ }
+
+ if !found["LD_PRELOAD"] {
+ t.Error("expected LD_PRELOAD to be in stripped list")
+ }
+ if !found["DYLD_INSERT_LIBRARIES"] {
+ t.Error("expected DYLD_INSERT_LIBRARIES to be in stripped list")
+ }
+}
+
+func TestFilterDangerousEnv_EmptyInput(t *testing.T) {
+ filtered := FilterDangerousEnv(nil)
+ if filtered == nil {
+ t.Error("expected non-nil slice for nil input")
+ }
+ if len(filtered) != 0 {
+ t.Errorf("expected empty slice, got %v", filtered)
+ }
+
+ filtered = FilterDangerousEnv([]string{})
+ if len(filtered) != 0 {
+ t.Errorf("expected empty slice, got %v", filtered)
+ }
+}
+
+func TestFilterDangerousEnv_AllDangerous(t *testing.T) {
+ env := []string{
+ "LD_PRELOAD=/tmp/evil.so",
+ "LD_LIBRARY_PATH=/tmp",
+ "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib",
+ }
+
+ filtered := FilterDangerousEnv(env)
+ if len(filtered) != 0 {
+ t.Errorf("expected all vars to be filtered, got %v", filtered)
+ }
+}
+
+func TestFilterDangerousEnv_AllSafe(t *testing.T) {
+ env := []string{
+ "PATH=/usr/bin",
+ "HOME=/home/user",
+ "USER=test",
+ }
+
+ filtered := FilterDangerousEnv(env)
+ if len(filtered) != 3 {
+ t.Errorf("expected all 3 vars to pass through, got %d", len(filtered))
+ }
+}
diff --git a/internal/sandbox/shell.go b/internal/sandbox/shell.go
index 4360f17..154a7d9 100644
--- a/internal/sandbox/shell.go
+++ b/internal/sandbox/shell.go
@@ -1,4 +1,3 @@
-// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (