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.

ParameterDescription
cfgConfiguration for the sandbox
debugEnable verbose logging (proxy activity, sandbox commands)
monitorLog 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, then allowWrite, then default deny. An empty policy denies all writes.
  • Reads: denyRead always wins. Without defaultDenyRead, everything else is readable. With defaultDenyRead, a path is readable when it matches allowRead, allowExecute, allowWrite (which implies read), or a default readable system path - unless strictDenyRead suppresses 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 fence CLI. Library embedders skip the Landlock layer; filesystem isolation still comes from bubblewrap mount rules.
  • runtimeExecPolicy: "argv" (Linux). The argv-aware exec supervisor also requires the fence CLI binary to host its helper modes. WrapCommand returns an error for this policy in library mode; use the default "path" policy instead.
  • Violation monitoring. The monitor flag on NewManager covers proxy denials only. The CLI's -m additionally starts platform monitors (macOS log 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; AllowPty in config still controls whether PTY allocation is permitted inside the sandbox.
  • Config discovery. The CLI auto-discovers the nearest fence.jsonc/fence.json and applies --template. Library callers load config explicitly (LoadConfigResolved, ResolveConfigPath).

Platform Differences

FeaturemacOSLinux
Sandbox mechanismsandbox-execbubblewrap
Network isolationHTTP/SOCKS proxyNetwork namespace + proxy
Filesystem restrictionsSeatbelt profilesBind mounts
RequirementsNonebubblewrap, socat

Thread Safety

  • Manager instances are not thread-safe
  • Create one manager per goroutine, or synchronize access
  • Proxies are shared and handle concurrent connections