Library Usage (Go)
July 4, 2026 · View on GitHub
Fence can be used as a Go library to sandbox commands programmatically.
Installation
go get github.com/fencesandbox/fence
Quick Start
package main
import (
"fmt"
"os/exec"
"github.com/fencesandbox/fence/pkg/fence"
)
func main() {
// Check platform support
if !fence.IsSupported() {
fmt.Println("Sandboxing not supported on this platform")
return
}
// Create config
cfg := &fence.Config{
Network: fence.NetworkConfig{
AllowedDomains: []string{"api.example.com"},
},
}
// Create and initialize manager
manager := fence.NewManager(cfg, false, false)
defer manager.Cleanup()
if err := manager.Initialize(); err != nil {
panic(err)
}
// Wrap the command
wrapped, err := manager.WrapCommand("curl https://api.example.com/data")
if err != nil {
panic(err)
}
// Execute it
cmd := exec.Command("sh", "-c", wrapped)
output, _ := cmd.CombinedOutput()
fmt.Println(string(output))
}
API Reference
Functions
IsSupported() bool
Returns true if the current platform supports sandboxing (macOS or Linux).
if !fence.IsSupported() {
log.Fatal("Platform not supported")
}
DefaultConfig() *Config
Returns a default configuration with all network blocked.
cfg := fence.DefaultConfig()
cfg.Network.AllowedDomains = []string{"example.com"}
LoadConfig(path string) (*Config, error)
Loads configuration from a JSON or JSONC file. The extension is not
inspected: comments and trailing commas are accepted regardless of whether
the file is named fence.json or fence.jsonc.
This is a low-level loader and does not resolve extends entries relative to
the config file location. Use LoadConfigResolved if your config may use
relative extends paths.
LoadConfigResolved(path string) (*Config, error)
Loads configuration from a JSON file and resolves extends entries relative to
that file's parent directory. This matches the CLI's behavior.
path := fence.ResolveDefaultConfigPath()
cfg, err := fence.LoadConfigResolved(path)
if err != nil {
log.Fatal(err)
}
if cfg == nil {
cfg = fence.DefaultConfig() // File doesn't exist
}
DefaultConfigPath() string
Returns the canonical config file path for new configs ($XDG_CONFIG_HOME/fence/fence.json on Linux, typically ~/.config/fence/fence.json; ~/.config/fence/fence.json on macOS).
ResolveDefaultConfigPath() string
Returns the config path fence should load by default. It uses the canonical path ($XDG_CONFIG_HOME/fence/fence.json on Linux, typically ~/.config/fence/fence.json; ~/.config/fence/fence.json on macOS) when that file exists, and otherwise falls back to legacy macOS ~/Library/Application Support/fence/fence.json and legacy ~/.fence.json when those files exist.
NewManager(cfg *Config, debug, monitor bool) *Manager
Creates a new sandbox manager.
| Parameter | Description |
|---|---|
cfg | Configuration for the sandbox |
debug | Enable verbose logging (proxy activity, sandbox commands) |
monitor | Log only violations (blocked requests) |
Manager Methods
Initialize() error
Sets up sandbox infrastructure (starts HTTP and SOCKS proxies). Called automatically by WrapCommand if not already initialized.
manager := fence.NewManager(cfg, false, false)
defer manager.Cleanup()
if err := manager.Initialize(); err != nil {
log.Fatal(err)
}
WrapCommand(command string) (string, error)
Wraps a shell command with sandbox restrictions. Returns an error if:
- The command is blocked by policy (
command.deny) - The platform is unsupported
- Initialization fails
wrapped, err := manager.WrapCommand("npm install")
if err != nil {
// Command may be blocked by policy
log.Fatal(err)
}
SetService(opts ServiceOptions)
Configures the sandboxed service's inbound-connectivity model. Must be called
before Initialize.
manager.SetService(fence.ServiceOptions{
Exposures: []fence.ExposedPort{
fence.LoopbackPort(3000),
fence.LoopbackPort(8080),
},
ExecutionModel: fence.ServiceBindsInSandbox, // default
})
fence.LoopbackPort(port) is sugar for the common case (bind on
127.0.0.1). For finer control set BindAddress directly - pass
"0.0.0.0" for LAN exposure, a specific interface IP, or "::" / "::1"
for IPv6:
manager.SetService(fence.ServiceOptions{
Exposures: []fence.ExposedPort{
{BindAddress: "127.0.0.1", Port: 3000}, // loopback (also: LoopbackPort(3000))
{BindAddress: "0.0.0.0", Port: 8080}, // LAN
{BindAddress: "192.168.1.10", Port: 9090}, // specific interface
{BindAddress: "::1", Port: 9091}, // IPv6 loopback
},
})
An empty BindAddress is normalized to fence.DefaultExposedBindAddress
(127.0.0.1).
For services whose start command delegates port binding to an external daemon
(e.g. docker compose up, podman run, systemctl start …) set
ExecutionModel: fence.ServiceBindsOnHost. Fence then skips setting up the
reverse bridge, which would otherwise collide with the daemon's own bind on
the host port.
manager.SetService(fence.ServiceOptions{
Exposures: []fence.ExposedPort{fence.LoopbackPort(8000)},
ExecutionModel: fence.ServiceBindsOnHost, // dockerd binds 8000 on the host
})
ExposeHostPath(path string, writable bool) error
Registers a host file or directory that must be visible inside the sandbox at the same path. Use this when you need to hand a host file (e.g. a caller-generated config or temp file) to the sandboxed process.
This decouples callers from fence's internal mount plan — in particular from
the fact that fence tmpfs-overmounts /tmp on Linux, which would otherwise
hide any file the caller wrote via os.CreateTemp("", ...). You don't need
to know where fence overmounts to pick a valid path; just call
ExposeHostPath with the path you already chose.
f, _ := os.CreateTemp("", "compose-override-*.yml")
_ = os.WriteFile(f.Name(), overrideYaml, 0o600)
manager.ExposeHostPath(f.Name(), false /* read-only */)
Must be called before WrapCommand. The path must exist at call time.
Cleanup()
Stops proxies and releases resources. Always call via defer.
HTTPPort() int / SOCKSPort() int
Returns the ports used by the filtering proxies.
Configuration Types
Config
type Config struct {
Extends string // Template to extend (e.g., "code")
Network NetworkConfig
Filesystem FilesystemConfig
MacOS MacOSConfig
Command CommandConfig
SSH SSHConfig
AllowPty bool // Allow PTY allocation
}
NetworkConfig
type NetworkConfig struct {
AllowedDomains []string // Domains to allow (supports *.example.com)
DeniedDomains []string // Domains to explicitly deny
AllowUnixSockets []string // Specific Unix socket paths to allow
AllowAllUnixSockets bool // Allow all Unix socket connections
AllowLocalBinding bool // Allow binding to localhost ports
AllowLocalOutbound *bool // Allow outbound to localhost (defaults to AllowLocalBinding)
HTTPProxyPort int // Override HTTP proxy port (0 = auto)
SOCKSProxyPort int // Override SOCKS proxy port (0 = auto)
UpstreamProxy string // Optional upstream HTTP proxy URL for grey-zone traffic (e.g. "http://127.0.0.1:8080")
DefaultAction string // Grey-zone fallback: "" or "deny" = block, "proxy" = forward to UpstreamProxy
}
MacOSConfig
type MacOSConfig struct {
Mach MachConfig
}
type MachConfig struct {
Lookup []string // Additional Mach/XPC services allowed for mach-lookup
Register []string // Additional Mach/XPC services allowed for mach-register
}
FilesystemConfig
type FilesystemConfig struct {
DenyRead []string // Paths to deny read access
AllowWrite []string // Paths to allow write access
DenyWrite []string // Paths to explicitly deny write access
AllowGitConfig bool // Allow read access to ~/.gitconfig
}
CommandConfig
type CommandConfig struct {
Deny []string // Command patterns to block
Allow []string // Exceptions to deny rules
UseDefaults *bool // Use default deny list (true if nil)
}
SSHConfig
type SSHConfig struct {
AllowedHosts []string // Host patterns to allow (supports wildcards)
DeniedHosts []string // Host patterns to deny
AllowedCommands []string // Commands allowed over SSH
DeniedCommands []string // Commands denied over SSH
AllowAllCommands bool // Use denylist mode instead of allowlist
InheritDeny bool // Apply global command.deny rules to SSH
}
Examples
Allow specific domains
cfg := &fence.Config{
Network: fence.NetworkConfig{
AllowedDomains: []string{
"registry.npmjs.org",
"*.github.com",
"api.openai.com",
},
},
}
Restrict filesystem access
cfg := &fence.Config{
Filesystem: fence.FilesystemConfig{
AllowWrite: []string{".", "/tmp"},
DenyRead: []string{"~/.ssh", "~/.aws"},
},
}
Block dangerous commands
cfg := &fence.Config{
Command: fence.CommandConfig{
Deny: []string{
"rm -rf /",
"git push",
"npm publish",
},
},
}
Expose dev server port
manager := fence.NewManager(cfg, false, false)
manager.SetService(fence.ServiceOptions{
Exposures: []fence.ExposedPort{fence.LoopbackPort(3000)},
})
defer manager.Cleanup()
wrapped, _ := manager.WrapCommand("npm run dev")
Expose a docker-compose stack (host-bound ports)
manager := fence.NewManager(cfg, false, false)
manager.SetService(fence.ServiceOptions{
Exposures: []fence.ExposedPort{fence.LoopbackPort(8000)},
ExecutionModel: fence.ServiceBindsOnHost,
})
defer manager.Cleanup()
wrapped, _ := manager.WrapCommand("docker compose up")
Hand a host-generated file to the sandboxed process
f, _ := os.CreateTemp("", "override-*.yml")
_ = os.WriteFile(f.Name(), overrideYaml, 0o600)
manager.ExposeHostPath(f.Name(), false)
wrapped, _ := manager.WrapCommand("docker compose -f base.yml -f " + f.Name() + " up")
Load and extend config
path := fence.ResolveDefaultConfigPath()
cfg, err := fence.LoadConfigResolved(path)
if err != nil {
log.Fatal(err)
}
if cfg == nil {
cfg = fence.DefaultConfig()
}
// Add additional restrictions
cfg.Command.Deny = append(cfg.Command.Deny, "dangerous-cmd")
Error Handling
WrapCommand returns an error when a command is blocked:
wrapped, err := manager.WrapCommand("git push origin main")
if err != nil {
// err.Error() = "command blocked by policy: git push origin main"
fmt.Println("Blocked:", err)
return
}
Policy Checks (Preflight)
The Check* functions evaluate a path, command, or URL against a config's
policy without running anything - no Manager, no proxies, no sandbox. Use
them when your program acts outside the sandbox (Go stdlib file tools, glob
walkers, agent read_file/write_file/web_fetch tools) and you want the
same fence.json to be the single source of truth for those operations too.
func CheckReadPath(cfg *Config, path, cwd string) error
func CheckWritePath(cfg *Config, path, cwd string) error
func CheckCommand(cfg *Config, command string) error
func CheckURL(cfg *Config, rawURL string) error
A nil error means the policy allows the operation. A non-nil error is a
typed error describing the denial; use errors.As to inspect it.
These are policy preflights, not enforcement. They check declared intent -
the kernel-level sandbox and traffic-time proxies that wrapped commands get
remain authoritative. The checkers exist so code paths that don't run
through WrapCommand can honor the same policy. They are the same
predicates Fence's hook integrations (Claude Code, Cursor, Hermes,
Windsurf, ...) use to preflight declared tool inputs.
Filesystem
CheckReadPath / CheckWritePath return *fence.PathBlockedError on deny:
type PathBlockedError struct {
Path string // cleaned absolute path that was evaluated
Op PathOp // fence.PathOpRead or fence.PathOpWrite
MatchedRule string // the rule text that matched; empty for default-deny
Reason string // "denyRead", "denyWrite", "dangerous path", "not in allowWrite", ...
}
Relative paths resolve against cwd; pass "" to require absolute paths.
cfg, _ := fence.LoadConfigResolved(fence.ResolveDefaultConfigPath())
if err := fence.CheckWritePath(cfg, req.Path, req.CWD); err != nil {
var blocked *fence.PathBlockedError
errors.As(err, &blocked)
return fmt.Sprintf("write blocked by policy: %s", blocked.Reason)
}
Semantics match wrap-mode enforcement:
- Writes: mandatory dangerous-path protection, then
denyWrite, thenallowWrite, then default deny. An empty policy denies all writes. - Reads:
denyReadalways wins. WithoutdefaultDenyRead, everything else is readable. WithdefaultDenyRead, a path is readable when it matchesallowRead,allowExecute,allowWrite(which implies read), or a default readable system path - unlessstrictDenyReadsuppresses the system paths.
Caveats: paths are evaluated lexically (no symlink resolution of the
target, no filesystem access), and only the config is consulted -
manager-level additions like ExposeHostPath are not reflected.
Commands
CheckCommand runs the same preflight WrapCommand performs: each
sub-command in pipelines, &&/||/; chains, and nested sh -c patterns
is checked against command.deny/command.allow (plus the built-in default
deny list unless useDefaults is false), and ssh invocations are checked
against the SSH policy. Denials are *fence.CommandBlockedError or
*fence.SSHBlockedError.
if err := fence.CheckCommand(cfg, "echo ok && git push origin main"); err != nil {
var blocked *fence.CommandBlockedError
if errors.As(err, &blocked) {
// blocked.Command = "git push origin main", blocked.BlockedPrefix = "git push"
}
}
Network URLs
CheckURL checks a URL's host against network.allowedDomains /
network.deniedDomains. Deny rules win; an empty allowedDomains denies
everything; "*" allows any host not explicitly denied. Denials are
*fence.URLBlockedError (URL, host, matched rule, reason).
if err := fence.CheckURL(cfg, "https://internal.example.com/admin"); err != nil {
// err.Error() = `network access to "..." blocked: deniedDomains (matched "...")`
}
CheckURL is strictly weaker than the traffic-time proxy enforcement
wrapped commands get: it validates the declared URL only, so redirects,
URLs embedded in query strings, and requests made by fetched content are
not covered. Use it to preflight tools that fetch outside the sandbox, not
as a substitute for wrapping them.
CLI vs Library Differences
WrapCommand gives library callers the same core sandbox as the CLI: command
policy preflight, macOS Seatbelt / Linux bubblewrap wrapping, proxy-based
network filtering, and mandatory dangerous-path protection. A few things the
fence CLI does around the wrapped command are not part of WrapCommand,
so they differ when embedding:
- Environment hardening. The CLI executes the wrapped command with a
hardened environment that strips library-injection variables (
LD_PRELOAD,LD_LIBRARY_PATH,DYLD_INSERT_LIBRARIES, ...). When you exec the wrapped string yourself, strip these variables from the child environment. - Linux Landlock layer. In-sandbox Landlock rules are applied by
re-executing the Fence binary in an internal helper mode, which only works
when the running executable is the
fenceCLI. Library embedders skip the Landlock layer; filesystem isolation still comes from bubblewrap mount rules. runtimeExecPolicy: "argv"(Linux). The argv-aware exec supervisor also requires thefenceCLI binary to host its helper modes.WrapCommandreturns an error for this policy in library mode; use the default"path"policy instead.- Violation monitoring. The
monitorflag onNewManagercovers proxy denials only. The CLI's-madditionally starts platform monitors (macOSlog stream, Linux eBPF) that are not exposed through the public API. - Interactive sessions. The CLI manages TTY foreground handoff, job
control, and signal forwarding around the wrapped command. Library callers
wire stdio themselves;
AllowPtyin config still controls whether PTY allocation is permitted inside the sandbox. - Config discovery. The CLI auto-discovers the nearest
fence.jsonc/fence.jsonand applies--template. Library callers load config explicitly (LoadConfigResolved,ResolveConfigPath).
Platform Differences
| Feature | macOS | Linux |
|---|---|---|
| Sandbox mechanism | sandbox-exec | bubblewrap |
| Network isolation | HTTP/SOCKS proxy | Network namespace + proxy |
| Filesystem restrictions | Seatbelt profiles | Bind mounts |
| Requirements | None | bubblewrap, socat |
Thread Safety
Managerinstances are not thread-safe- Create one manager per goroutine, or synchronize access
- Proxies are shared and handle concurrent connections