Refactor and improve documentation, add examples

This commit is contained in:
JY Tan
2025-12-23 18:43:07 -08:00
parent b98b640f5a
commit 8db245f56e
32 changed files with 1348 additions and 162 deletions

View File

@@ -302,93 +302,4 @@ With `-m` on Linux, you only see proxy-level denials:
## Security Model ## Security Model
### How Each Layer Works See [`docs/security-model.md`](docs/security-model.md) for Fence's threat model, guarantees, and limitations.
#### Network Isolation
All outbound connections are routed through local HTTP/SOCKS5 proxies that filter by domain:
- Direct socket connections are blocked at the OS level (syscall filtering on macOS, network namespace on Linux)
- Only localhost connections to the proxy ports are allowed
- The proxy inspects the target domain and allows/denies based on config
- Environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`) route application traffic
#### Filesystem Restrictions
Access control follows a deny-by-default model for writes:
| Operation | Default | Config |
|-----------|---------|--------|
| Read | Allow all | `denyRead` blocks specific paths |
| Write | Deny all | `allowWrite` permits specific paths |
| Write exceptions | - | `denyWrite` overrides `allowWrite` |
#### Dangerous File Protection
Certain paths are always protected from writes regardless of config to prevent common attack vectors:
**Protected files:**
- Shell configs: `.bashrc`, `.bash_profile`, `.zshrc`, `.zprofile`, `.profile`
- Git config: `.gitconfig`, `.gitmodules`, `.git/config` (can define aliases that run code)
- Git hooks: `.git/hooks/*` (can execute arbitrary code on git operations)
- Tool configs: `.ripgreprc`, `.mcp.json`
**Protected directories:**
- IDE/editor settings: `.vscode`, `.idea`
- Claude agent configs: `.claude/commands`, `.claude/agents`
#### Process Isolation
- Commands run in isolated namespaces (Linux) or with syscall restrictions (macOS)
- PID namespace isolation prevents seeing/signaling host processes (Linux)
- New session prevents terminal control attacks
### What Fence Blocks
| Attack Vector | How It's Blocked |
|---------------|------------------|
| Arbitrary network access | Proxy filtering + OS-level enforcement |
| Data exfiltration to unknown hosts | Only allowlisted domains reachable |
| Writing malicious git hooks | `.git/hooks` always protected |
| Modifying shell startup files | `.bashrc`, `.zshrc`, etc. always protected |
| Reading sensitive paths | Configurable via `denyRead` |
| Arbitrary filesystem writes | Only `allowWrite` paths permitted |
### What Fence Allows
- Network access to explicitly allowed domains
- Filesystem reads (except `denyRead` paths)
- Filesystem writes to `allowWrite` paths
- Process spawning within the sandbox
- Inbound connections on exposed ports (`-p` flag)
### Limitations
#### Domain-based filtering only
Fence doesn't inspect request content. A sandboxed process can:
- Download arbitrary code from allowed domains
- Exfiltrate data by encoding it in requests to allowed domains
- Use allowed domains as tunnels if they support it
You're trusting your allowlist - if you allow `*.github.com`, the sandbox can reach any GitHub-hosted content.
#### Root processes may escape restrictions
- *Linux*: The sandboxed process has root inside its namespace and could potentially manipulate mounts, cgroups, or exploit kernel vulnerabilities. Network namespace isolation (`--unshare-net`) is solid, but filesystem isolation via bind mounts is more permeable.
- *macOS*: `sandbox-exec` is robust at the kernel level, but root can modify system state before the sandbox starts or (on older macOS) load kernel extensions.
#### macOS sandbox-exec is deprecated
Apple deprecated `sandbox-exec` but it still works on current macOS (including Sequoia). There's no good alternative for CLI sandboxing:
- App Sandbox requires signed `.app` bundles
- Virtualization.framework is heavyweight
- We use `sandbox-exec` pragmatically until Apple removes it
#### Not for hostile code containment
Fence is defense-in-depth for running semi-trusted code (npm install, build scripts, CI jobs), not a security boundary against actively malicious software designed to escape sandboxes.

View File

@@ -1,10 +1,10 @@
# fence # Fence
![GitHub Release](https://img.shields.io/github/v/release/Use-Tusk/fence) ![GitHub Release](https://img.shields.io/github/v/release/Use-Tusk/fence)
A Go implementation of process sandboxing with network and filesystem restrictions. A Go implementation of process sandboxing with network and filesystem restrictions.
`fence` wraps commands in a sandbox that blocks network access by default and restricts filesystem operations based on configurable rules. Useful for AI coding agents, untrusted code execution, or running processes with controlled side effects. Fence wraps commands in a sandbox that blocks network access by default and restricts filesystem operations based on configurable rules. It's most useful for running semi-trusted code (package installs, build scripts, CI jobs, unfamiliar repos) with controlled side effects, and it can also complement AI coding agents as defense-in-depth.
## Features ## Features
@@ -15,7 +15,7 @@ A Go implementation of process sandboxing with network and filesystem restrictio
- **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap) - **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap)
- **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control - **HTTP/SOCKS5 Proxies**: Built-in filtering proxies for domain control
You can use `fence` as a Go package or CLI tool. You can use Fence as a Go package or CLI tool.
## Installation ## Installation
@@ -44,51 +44,7 @@ fence -c "echo hello && ls"
fence -d curl https://example.com fence -d curl https://example.com
``` ```
## Configuration For a more detailed introduction, see the [Quickstart Guide](docs/quickstart.md).
Create `~/.fence.json` to configure allowed domains and filesystem access:
```json
{
"network": {
"allowedDomains": ["github.com", "*.npmjs.org", "registry.yarnpkg.com"],
"deniedDomains": ["evil.com"]
},
"filesystem": {
"denyRead": ["/etc/passwd"],
"allowWrite": [".", "/tmp"],
"denyWrite": [".git/hooks"]
}
}
```
### Network Configuration
| Field | Description |
|-------|-------------|
| `allowedDomains` | List of allowed domains. Supports wildcards like `*.example.com` |
| `deniedDomains` | List of denied domains (checked before allowed) |
| `allowUnixSockets` | List of allowed Unix socket paths (macOS) |
| `allowAllUnixSockets` | Allow all Unix sockets |
| `allowLocalBinding` | Allow binding to local ports |
| `allowLocalOutbound` | Allow outbound connections to localhost, e.g., local DBs (defaults to `allowLocalBinding` if not set) |
| `httpProxyPort` | Fixed port for HTTP proxy (default: random available port) |
| `socksProxyPort` | Fixed port for SOCKS5 proxy (default: random available port) |
### Filesystem Configuration
| Field | Description |
|-------|-------------|
| `denyRead` | Paths to deny reading (deny-only pattern) |
| `allowWrite` | Paths to allow writing |
| `denyWrite` | Paths to deny writing (takes precedence) |
| `allowGitConfig` | Allow writes to `.git/config` files |
### Other Options
| Field | Description |
|-------|-------------|
| `allowPty` | Allow pseudo-terminal (PTY) allocation in the sandbox (for MacOS) |
## CLI Usage ## CLI Usage
@@ -174,25 +130,12 @@ func main() {
} }
``` ```
## How It Works ## Documentation
### macOS (sandbox-exec) - [Documentation index](docs/)
- [Security model](docs/security-model.md)
On macOS, fence uses Apple's `sandbox-exec` with a generated seatbelt profile that: - [Architecture](ARCHITECTURE.md)
- [Examples](examples/)
- Denies all operations by default
- Allows specific Mach services needed for basic operation
- Controls network access via localhost proxies
- Restricts filesystem read/write based on configuration
### Linux (bubblewrap)
On Linux, fence uses `bubblewrap` (bwrap) with:
- Network namespace isolation (`--unshare-net`)
- Filesystem bind mounts for access control
- PID namespace isolation
- Unix socket bridges for proxy communication
For detailed security model, limitations, and architecture, see [ARCHITECTURE.md](ARCHITECTURE.md). For detailed security model, limitations, and architecture, see [ARCHITECTURE.md](ARCHITECTURE.md).
@@ -208,12 +151,6 @@ For detailed security model, limitations, and architecture, see [ARCHITECTURE.md
- `bubblewrap` (for sandboxing) - `bubblewrap` (for sandboxing)
- `socat` (for network bridging) - `socat` (for network bridging)
Install on Ubuntu/Debian:
```bash
apt install bubblewrap socat
```
## Attribution ## Attribution
Portions of this project are derived from Anthropic's [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) (Apache-2.0). This repository contains modifications and additional original work. Portions of this project are derived from Anthropic's [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) (Apache-2.0). This repository contains modifications and additional original work.

51
docs/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Fence Documentation
Fence is a sandboxing tool that restricts **network** and **filesystem** access for arbitrary commands. It's most useful for running semi-trusted code (package installs, build scripts, CI jobs, unfamiliar repos) with controlled side effects.
## Getting Started
- **[Quickstart](quickstart.md)** - Install fence and run your first sandboxed command in 5 minutes
- **[Why Fence](why-fence.md)** - What problem it solves (and what it doesn't)
## Guides
- **[Concepts](concepts.md)** - Mental model: OS sandbox + local proxies + config
- **[Troubleshooting](troubleshooting.md)** - Common failure modes and fixes
- **[Using Fence with AI Agents](agents.md)** - Defense-in-depth and policy standardization
- **[Recipes](recipes/README.md)** - Common workflows (npm/pip/git/CI)
- **[Config Templates](templates/)** - Copy/paste templates you can start from
## Reference
- [README](../README.md) - CLI usage + configuration reference
- [Architecture](../ARCHITECTURE.md) - How fence works under the hood
- [Security Model](security-model.md) - Threat model, guarantees, and limitations
- [Security Policy](../SECURITY.md) - Vulnerability reporting policy
## Examples
See [`examples/`](../examples/README.md) for runnable demos.
## Quick Reference
### Common commands
```bash
# Block all network (default)
fence <command>
# Use custom config
fence --settings ./fence.json <command>
# Debug mode (verbose output)
fence -d <command>
# Monitor mode (show blocked requests)
fence -m <command>
# Expose port for servers
fence -p 3000 <command>
# Run shell command
fence -c "echo hello && ls"
```

47
docs/agents.md Normal file
View File

@@ -0,0 +1,47 @@
# Using Fence with AI Agents
Many popular coding agents already include sandboxing. Fence can still be useful when you want a **tool-agnostic** policy layer that works the same way across:
- local developer machines
- CI jobs
- custom/internal agents or automation scripts
- different agent products (as defense-in-depth)
## Recommended approach
Treat an agent as "semi-trusted automation":
- **Restrict writes** to the workspace (and maybe `/tmp`)
- **Allowlist only the network destinations** you actually need
- Use `-m` (monitor mode) to audit blocked attempts and tighten policy
Fence can also reduce the risk of running agents with fewer interactive permission prompts (e.g. "skip permissions"), **as long as your Fence config tightly scopes writes and outbound destinations**. It's defense-in-depth, not a substitute for the agent's own safeguards.
## Example: API-only agent
```json
{
"network": {
"allowedDomains": ["api.openai.com", "api.anthropic.com"]
},
"filesystem": {
"allowWrite": ["."]
}
}
```
Run:
```bash
fence --settings ./fence.json <agent-command>
```
## Protecting your environment
Fence includes additional "dangerous file protection (writes blocked regardless of config) to reduce persistence and environment-tampering vectors like:
- `.git/hooks/*`
- shell startup files (`.zshrc`, `.bashrc`, etc.)
- some editor/tool config directories
See `ARCHITECTURE.md` for the full list and rationale.

56
docs/concepts.md Normal file
View File

@@ -0,0 +1,56 @@
# Concepts
Fence combines two ideas:
1. **An OS sandbox** to enforce "no direct network" and restrict filesystem operations.
2. **Local filtering proxies** (HTTP + SOCKS5) to selectively allow outbound traffic by domain.
## Network model
By default, fence blocks all outbound network access.
When you allow domains, fence:
- Starts local HTTP and SOCKS5 proxies
- Sets proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`)
- Allows the sandboxed process to connect only to the local proxies
- Filters outbound connections by **destination domain**
### Localhost controls
- `allowLocalBinding`: lets a sandboxed process *listen* on local ports (e.g. dev servers).
- `allowLocalOutbound`: lets a sandboxed process connect to `localhost` services (e.g. Redis/Postgres on your machine).
- `-p/--port`: exposes inbound ports so things outside the sandbox can reach your server.
These are separate on purpose. A typical safe default for dev servers is:
- allow binding + expose just the needed port(s)
- disallow localhost outbound unless you explicitly need it
## Filesystem model
Fence is designed around "read mostly, write narrowly":
- **Reads**: allowed by default (you can block specific paths via `denyRead`).
- **Writes**: denied by default (you must opt-in with `allowWrite`).
- **denyWrite**: overrides `allowWrite` (useful for protecting secrets and dangerous files).
Fence also protects some dangerous targets regardless of config (e.g. shell startup files and git hooks). See `ARCHITECTURE.md` for the full list.
## Debug vs Monitor mode
- `-d/--debug`: verbose output (proxy activity, filter decisions, sandbox command details).
- `-m/--monitor`: show blocked requests/violations only (great for auditing and policy tuning).
Workflow tip:
1. Start restrictive.
2. Run with `-m` to see what gets blocked.
3. Add the minimum domains/paths required.
## Platform notes
- **macOS**: uses `sandbox-exec` with generated Seatbelt profiles.
- **Linux**: uses `bubblewrap` for namespaces + `socat` bridges to connect the isolated network namespace to host-side proxies.
If you want the under-the-hood view, see [Architecture](../ARCHITECTURE.md).

52
docs/configuration.md Normal file
View File

@@ -0,0 +1,52 @@
# Configuration
Fence reads settings from `~/.fence.json` by default (or pass `--settings ./fence.json`).
Example config:
```json
{
"network": {
"allowedDomains": ["github.com", "*.npmjs.org", "registry.yarnpkg.com"],
"deniedDomains": ["evil.com"]
},
"filesystem": {
"denyRead": ["/etc/passwd"],
"allowWrite": [".", "/tmp"],
"denyWrite": [".git/hooks"]
}
}
```
## Network Configuration
| Field | Description |
|-------|-------------|
| `allowedDomains` | List of allowed domains. Supports wildcards like `*.example.com` |
| `deniedDomains` | List of denied domains (checked before allowed) |
| `allowUnixSockets` | List of allowed Unix socket paths (macOS) |
| `allowAllUnixSockets` | Allow all Unix sockets |
| `allowLocalBinding` | Allow binding to local ports |
| `allowLocalOutbound` | Allow outbound connections to localhost, e.g., local DBs (defaults to `allowLocalBinding` if not set) |
| `httpProxyPort` | Fixed port for HTTP proxy (default: random available port) |
| `socksProxyPort` | Fixed port for SOCKS5 proxy (default: random available port) |
## Filesystem Configuration
| Field | Description |
|-------|-------------|
| `denyRead` | Paths to deny reading (deny-only pattern) |
| `allowWrite` | Paths to allow writing |
| `denyWrite` | Paths to deny writing (takes precedence) |
| `allowGitConfig` | Allow writes to `.git/config` files |
## Other Options
| Field | Description |
|-------|-------------|
| `allowPty` | Allow pseudo-terminal (PTY) allocation in the sandbox (for MacOS) |
## See Also
- Config templates: [`docs/templates/`](docs/templates/)
- Workflow guides: [`docs/recipes/`](docs/recipes/)

128
docs/quickstart.md Normal file
View File

@@ -0,0 +1,128 @@
# Quickstart
## Installation
### From Source (recommended for now)
```bash
git clone https://github.com/Use-Tusk/fence
cd fence
go build -o fence ./cmd/fence
sudo mv fence /usr/local/bin/
```
### Using Go Install
```bash
go install github.com/Use-Tusk/fence/cmd/fence@latest
```
### Linux Dependencies
On Linux, you also need:
```bash
# Ubuntu/Debian
sudo apt install bubblewrap socat
# Fedora
sudo dnf install bubblewrap socat
# Arch
sudo pacman -S bubblewrap socat
```
## Verify Installation
```bash
fence --version
```
## Your First Sandboxed Command
By default, fence blocks all network access:
```bash
# This will fail - network is blocked
fence curl https://example.com
```
You should see something like:
```text
curl: (56) CONNECT tunnel failed, response 403
```
## Allow Specific Domains
Create a config file at `~/.fence.json`:
```json
{
"network": {
"allowedDomains": ["example.com"]
}
}
```
Now try again:
```bash
fence curl https://example.com
```
This time it succeeds!
## Debug Mode
Use `-d` to see what's happening under the hood:
```bash
fence -d curl https://example.com
```
This shows:
- The sandbox command being run
- Proxy activity (allowed/blocked requests)
- Filter rule matches
## Monitor Mode
Use `-m` to see only violations and blocked requests:
```bash
fence -m npm install
```
This is useful for:
- Auditing what a command tries to access
- Debugging why something isn't working
- Understanding a package's network behavior
## Running Shell Commands
Use `-c` to run compound commands:
```bash
fence -c "echo hello && ls -la"
```
## Expose Ports for Servers
If you're running a server that needs to accept connections:
```bash
fence -p 3000 -c "npm run dev"
```
This allows external connections to port 3000 while keeping outbound network restricted.
## Next steps
- Read **[Why Fence](why-fence.md)** to understand when fence is a good fit (and when it isn't).
- Learn the mental model in **[Concepts](concepts.md)**.
- Use **[Troubleshooting](troubleshooting.md)** if something is blocked unexpectedly.
- Start from copy/paste configs in **[`docs/templates/`](templates/README.md)**.
- Follow workflow-specific guides in **[Recipes](recipes/README.md)** (npm/pip/git/CI).

20
docs/recipes/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Recipes
These are "cookbook" guides for common workflows. Most follow the same loop:
1. Start with a restrictive config
2. Run with monitor mode (`-m`) to see what gets blocked
3. Allow the minimum domains/paths needed
## Package installs
- [npm install](npm-install.md)
- [pip / poetry](pip-poetry.md)
## Git / fetching code
- [git clone / git fetch](git-clone.md)
## CI
- [CI jobs](ci.md)

36
docs/recipes/ci.md Normal file
View File

@@ -0,0 +1,36 @@
# Recipe: CI jobs
Goal: make CI steps safer by default: minimal egress and controlled writes.
## Suggested baseline
```json
{
"network": {
"allowedDomains": []
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}
```
Run:
```bash
fence --settings ./fence.json -c "make test"
```
## Add only what you need
Use monitor mode to discover what a job tries to reach:
```bash
fence -m --settings ./fence.json -c "make test"
```
Then allowlist only:
- your artifact/cache endpoints
- the minimum package registries required
- any internal services the job must access

32
docs/recipes/git-clone.md Normal file
View File

@@ -0,0 +1,32 @@
# Recipe: `git clone` / `git fetch`
Goal: allow fetching code from a limited set of hosts.
## HTTPS clone (GitHub example)
```json
{
"network": {
"allowedDomains": ["github.com", "api.github.com", "codeload.github.com"]
},
"filesystem": {
"allowWrite": ["."]
}
}
```
Run:
```bash
fence --settings ./fence.json git clone https://github.com/OWNER/REPO.git
```
## SSH clone
SSH traffic may go through SOCKS5 (`ALL_PROXY`) depending on your git/ssh configuration.
If it fails, use monitor/debug mode to see what was blocked:
```bash
fence -m --settings ./fence.json git clone git@github.com:OWNER/REPO.git
```

View File

@@ -0,0 +1,37 @@
# Recipe: `npm install`
Goal: allow npm to fetch packages, but block unexpected egress.
## Start restrictive
```json
{
"network": {
"allowedDomains": ["registry.npmjs.org", "*.npmjs.org"]
},
"filesystem": {
"allowWrite": [".", "node_modules", "/tmp"]
}
}
```
Run:
```bash
fence --settings ./fence.json npm install
```
## Iterate with monitor mode
If installs fail, run:
```bash
fence -m --settings ./fence.json npm install
```
Then add the minimum extra domains required for your workflow (private registries, GitHub tarballs, etc.).
Notes:
- If your dependencies fetch binaries during install, you may need to allow additional domains.
- Keep allowlists narrow; prefer specific hostnames over broad wildcards.

View File

@@ -0,0 +1,36 @@
# Recipe: `pip` / `poetry`
Goal: allow Python dependency fetching while keeping egress minimal.
## Start restrictive (PyPI)
```json
{
"network": {
"allowedDomains": ["pypi.org", "files.pythonhosted.org"]
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}
```
Run:
```bash
fence --settings ./fence.json pip install -r requirements.txt
```
For Poetry:
```bash
fence --settings ./fence.json poetry install
```
## Iterate with monitor mode
```bash
fence -m --settings ./fence.json poetry install
```
If you use private indexes, add those domains explicitly.

75
docs/security-model.md Normal file
View File

@@ -0,0 +1,75 @@
# Security Model
Fence is intended as **defense-in-depth** for running semi-trusted commands with reduced side effects (package installs, build scripts, CI jobs, unfamiliar repos).
It is **not** designed to be a strong isolation boundary against actively malicious code that is attempting to escape.
## Threat model (what Fence helps with)
Fence is useful when you want to reduce risk from:
- Supply-chain scripts that unexpectedly call out to the network
- Tools that write broadly across your filesystem
- Accidental leakage of secrets via "phone home" behavior
- Unfamiliar repos that run surprising commands during install/build/test
## What Fence enforces
### Network
- **Default deny**: outbound network is blocked unless explicitly allowed.
- **Allowlisting by domain**: you can specify `allowedDomains` (with wildcard support like `*.example.com`).
- **Localhost controls**: inbound binding and localhost outbound are separately controlled.
Important: domain filtering does **not** inspect content. If you allow a domain, code can exfiltrate via that domain.
#### How allowlisting works (important nuance)
Fence combines **OS-level enforcement** with **proxy-based allowlisting**:
- The OS sandbox / network namespace is expected to block **direct outbound** connections.
- Domain allowlisting happens via local HTTP/SOCKS proxies and proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`).
If a program does not use proxy env vars (or uses a custom protocol/stack), it may **not benefit from domain allowlisting**. In that case it typically fails with connection errors rather than being "selectively allowed."
Localhost is separate from "external domains":
- `allowLocalOutbound=false` can intentionally block connections to local services like Redis on `127.0.0.1:6379` (see the dev-server example).
### Filesystem
- **Writes are denied by default**; you must opt in with `allowWrite`.
- **denyWrite** can block specific files/patterns even if the parent directory is writable.
- **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.
## Visibility / auditing
- `-m/--monitor` helps you discover what a command *tries* to access (blocked only).
- `-d/--debug` shows more detail to understand why something was blocked.
## Limitations (what Fence does NOT try to solve)
- **Hostile code containment**: assume determined attackers may escape via kernel/OS vulnerabilities.
- **Resource limits**: CPU, memory, disk, fork bombs, etc. are out of scope.
- **Content-based controls**: Fence does not block data exfiltration to *allowed* destinations.
- **Proxy limitations / protocol edge cases**: some programs may not respect proxy environment variables, so they won't get domain allowlisting unless you configure them to use a proxy (e.g. Node.js `http`/`https` without a proxy-aware client).
### Practical examples of proxy limitations
The proxy approach works well for many tools (curl, wget, git, npm, pip), but **not by default** for some stacks:
- Node.js native `http`/`https` (use a proxy-aware client, e.g. `undici` + `ProxyAgent`)
- Raw socket connections (custom TCP/UDP protocols)
Fence's OS-level sandbox is still expected to block direct outbound connections; bypassing the proxy should fail rather than silently succeeding.
### Domain-based filtering only
Fence does not inspect request content. If you allow a domain, a sandboxed process can still exfiltrate data to that domain.
### Not a hostile-code containment boundary
Fence is defense-in-depth for running semi-trusted code, not a strong isolation boundary against malware designed to escape sandboxes.
For implementation details (how proxies/sandboxes/bridges work), see [`ARCHITECTURE.md`](../ARCHITECTURE.md).

18
docs/templates/README.md vendored Normal file
View File

@@ -0,0 +1,18 @@
# Config Templates
This directory contains Fence config templates. They are small and meant to be copied and customized.
## Templates
- `default-deny.json`: no network allowlist; no write access (most restrictive)
- `workspace-write.json`: allow writes in the current directory
- `npm-install.json`: allow npm registry; allow writes to workspace/node_modules/tmp
- `pip-install.json`: allow PyPI; allow writes to workspace/tmp
- `local-dev-server.json`: allow binding and localhost outbound; allow writes to workspace/tmp
- `agent-api-only.json`: allow common LLM API domains; allow writes to workspace
## Using a template
```bash
fence --settings ./docs/templates/npm-install.json npm install
```

8
docs/templates/agent-api-only.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"network": {
"allowedDomains": ["api.openai.com", "api.anthropic.com"]
},
"filesystem": {
"allowWrite": ["."]
}
}

8
docs/templates/default-deny.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"network": {
"allowedDomains": []
},
"filesystem": {
"allowWrite": []
}
}

9
docs/templates/local-dev-server.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"network": {
"allowLocalBinding": true,
"allowLocalOutbound": true
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}

8
docs/templates/npm-install.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"network": {
"allowedDomains": ["registry.npmjs.org", "*.npmjs.org"]
},
"filesystem": {
"allowWrite": [".", "node_modules", "/tmp"]
}
}

8
docs/templates/pip-install.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"network": {
"allowedDomains": ["pypi.org", "files.pythonhosted.org"]
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}

5
docs/templates/workspace-write.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"filesystem": {
"allowWrite": ["."]
}
}

79
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,79 @@
# Troubleshooting
## "curl: (56) CONNECT tunnel failed, response 403"
This usually means:
- the process tried to reach a domain that is **not allowed**, and
- the request went through fence's HTTP proxy, which returned `403`.
Fix:
- Run with monitor mode to see what was blocked:
- `fence -m <command>`
- Add the required destination(s) to `network.allowedDomains`.
## "It works outside fence but not inside"
Start with:
- `fence -m <command>` to see what's being denied
- `fence -d <command>` to see full proxy and sandbox detail
Common causes:
- Missing `allowedDomains`
- A tool attempting direct sockets that don't respect proxy environment variables
- Localhost outbound blocked (DB/cache on `127.0.0.1`)
- Writes blocked (you didn't include a directory in `filesystem.allowWrite`)
## Node.js HTTP(S) doesn't use proxy env vars by default
Node's built-in `http`/`https` modules ignore `HTTP_PROXY`/`HTTPS_PROXY`.
If your Node code makes outbound HTTP(S) requests, use a proxy-aware client.
For example with `undici`:
```javascript
import { ProxyAgent, fetch } from "undici";
const proxyUrl = process.env.HTTPS_PROXY;
const response = await fetch(url, {
dispatcher: new ProxyAgent(proxyUrl),
});
```
Fence's OS-level sandbox should still block direct connections; the above makes your requests go through the filtering proxy so allowlisting works as intended.
## Local services (Redis/Postgres/etc.) fail inside the sandbox
If your process needs to connect to `localhost` services, set:
```json
{
"network": { "allowLocalOutbound": true }
}
```
If you're running a server inside the sandbox that must accept connections:
- set `network.allowLocalBinding: true` (to bind)
- use `-p <port>` (to expose inbound port(s))
## "Permission denied" on file writes
Writes are denied by default.
- Add the minimum required writable directories to `filesystem.allowWrite`.
- Protect sensitive targets with `filesystem.denyWrite` (and note fence protects some targets regardless).
Example:
```json
{
"filesystem": {
"allowWrite": [".", "/tmp"],
"denyWrite": [".env", "*.key"]
}
}
```

40
docs/why-fence.md Normal file
View File

@@ -0,0 +1,40 @@
# Why Fence?
Fence exists to reduce the blast radius of running commands you don't fully trust (or don't fully understand yet).
Common situations:
- Running `npm install`, `pip install`, or `cargo build` in an unfamiliar repo
- Executing build scripts or test runners that can read/write broadly and make network calls
- Running CI jobs where you want **default-deny egress** and **tightly scoped writes**
- Auditing what a command *tries* to do before you let it do it
Fence is intentionally simple: it focuses on **network allowlisting** (by domain) and **filesystem write restrictions** (by path), wrapped in a pragmatic OS sandbox (macOS `sandbox-exec`, Linux `bubblewrap`).
## What problem does it solve?
Fence helps you answer: "What can this command touch?"
- **Network**: block all outbound by default; then allow only the domains you choose.
- **Filesystem**: default-deny writes; then allow writes only where you choose (and deny sensitive writes regardless).
- **Visibility**: monitor blocked requests/violations (`-m`) to iteratively tighten or expand policy.
This is especially useful for supply-chain risk and "unknown repo" workflows where you want a safer default than "run it and hope".
## When Fence is useful even if tools already sandbox
Some coding agents and platforms ship sandboxing (Seatbelt/Landlock/etc.). Fence still provides value when you want:
- **Tool-agnostic policy**: apply the same rules to any command, not only inside one agent.
- **Standardization**: commit/review a config once, use it across developers and CI.
- **Defense-in-depth**: wrap an agent (or its subprocesses) with an additional layer and clearer audit signals.
- **Practical allowlisting**: start with default-deny egress and use `-m` to discover what domains a workflow actually needs.
## Non-goals
Fence is **not** a hardened containment boundary for actively malicious code.
- It does **not** attempt to prevent resource exhaustion (CPU/RAM/disk), timing attacks, or kernel-level escapes.
- Domain allowlisting is not content inspection: if you allow a domain, code can exfiltrate via that domain.
For details, see [Security Model](security-model.md).

23
examples/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Dependencies
node_modules/
venv/
__pycache__/
# Build outputs
build-output/
dist/
generated/
# Lock files (we want fresh installs for demos)
package-lock.json
yarn.lock
pnpm-lock.yaml
# Python
*.pyc
*.egg-info/
# Temp files
*.log
*.tmp

View File

@@ -0,0 +1,89 @@
# Dev Server + Redis Demo
This demo shows how fence controls network access: allowing specific external domains while blocking (or allowing) localhost connections.
## Prerequisites
You need Redis running on localhost:6379:
```bash
docker run -p 6379:6379 redis:alpine
```
## Install
```bash
npm install
```
## Demo 1: Localhost allowed, external blocked
This shows that requests to Redis (local service) works, but external requests are blocked.
```bash
fence -p 3000 --settings fence-external-blocked.json npm start
```
Test it:
```bash
# Works - localhost outbound to Redis allowed
curl http://localhost:3000/api/users
# Blocked - no domains whitelisted for external requests
curl http://localhost:3000/api/external
```
## Demo 2: External Allowed, Localhost Blocked
This shows the opposite: whitelisted external domains work, but Redis (localhost) is blocked.
```bash
fence -p 3000 --settings fence-external-only.json npm start
```
You will immediately notice that Redis connection is blocked on app startup:
```text
[app] Redis connection failed: connect EPERM 127.0.0.1:6379 - Local (0.0.0.0:0)
```
Test it:
```bash
# Works - httpbin.org is in the allowlist
curl http://localhost:3000/api/external
# Blocked - localhost outbound to Redis not allowed
curl http://localhost:3000/api/users
```
## Summary
| Config | Redis (localhost) | External (httpbin.org) |
|--------|-------------------|------------------------|
| `fence-external-blocked.json` | ✓ Allowed | ✗ Blocked |
| `fence-external-only.json` | ✗ Blocked | ✓ Allowed |
## Key Settings
| Setting | Purpose |
|---------|---------|
| `allowLocalBinding` | Server can listen on ports |
| `allowLocalOutbound` | App can connect to localhost services |
| `allowedDomains` | Whitelist of external domains |
## Note: Node.js Proxy Support
Node.js's native `http`/`https` modules don't respect proxy environment variables. This demo uses [`undici`](https://github.com/nodejs/undici) with `ProxyAgent` to route requests through fence's proxy:
```javascript
import { ProxyAgent, fetch } from "undici";
const proxyUrl = process.env.HTTPS_PROXY;
const response = await fetch(url, {
dispatcher: new ProxyAgent(proxyUrl),
});
```
Without this, external HTTP requests would fail with connection errors (the sandbox blocks them) rather than going through fence's proxy.

View File

@@ -0,0 +1,200 @@
/**
* Demo Express app that:
* 1. Serves an API on port 3000
* 2. Connects to Redis on localhost:6379
* 3. Attempts to call external APIs (blocked by fence)
*
* This demonstrates allowLocalOutbound - the app can reach
* local services (Redis) but not the external internet.
*/
import express from "express";
import Redis from "ioredis";
import { ProxyAgent, fetch as undiciFetch } from "undici";
const app = express();
const PORT = 3000;
// Connect to Redis on localhost
const redis = new Redis({
host: "127.0.0.1",
port: 6379,
connectTimeout: 3000,
retryStrategy: () => null, // Don't retry, fail fast for demo
});
let redisConnected = false;
redis.on("connect", () => {
redisConnected = true;
console.log("[app] Connected to Redis");
// Seed some demo data
redis.set(
"user:1",
JSON.stringify({ id: 1, name: "Alice", email: "alice@example.com" })
);
redis.set(
"user:2",
JSON.stringify({ id: 2, name: "Bob", email: "bob@example.com" })
);
redis.set(
"user:3",
JSON.stringify({ id: 3, name: "Charlie", email: "charlie@example.com" })
);
console.log("[app] Seeded demo data");
});
redis.on("error", (err) => {
if (!redisConnected) {
console.log("[app] Redis connection failed:", err.message);
}
});
// Helper: Make external API call using undici with proxy support
// Node.js native https doesn't respect HTTP_PROXY, so we use undici
async function fetchExternal(url) {
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const options = {
signal: AbortSignal.timeout(5000),
};
// Use proxy if available (set by fence)
if (proxyUrl) {
options.dispatcher = new ProxyAgent(proxyUrl);
}
const response = await undiciFetch(url, options);
const text = await response.text();
return {
status: response.status,
data: text.slice(0, 200),
};
}
// Routes
app.get("/", (req, res) => {
res.json({
message: "Dev Server Demo",
redis: redisConnected ? "connected" : "disconnected",
endpoints: {
"/api/users": "List all users from Redis",
"/api/users/:id": "Get user by ID from Redis",
"/api/health": "Health check",
"/api/external": "Try to call external API (blocked by fence)",
},
});
});
app.get("/api/users", async (req, res) => {
if (!redisConnected) {
return res.status(503).json({
error: "Redis not connected",
hint: "Start Redis: docker run -p 6379:6379 redis:alpine",
});
}
try {
const keys = await redis.keys("user:*");
const users = await Promise.all(
keys.map(async (key) => JSON.parse(await redis.get(key)))
);
res.json({
source: "redis",
count: users.length,
data: users,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get("/api/users/:id", async (req, res) => {
if (!redisConnected) {
return res.status(503).json({
error: "Redis not connected",
hint: "Start Redis: docker run -p 6379:6379 redis:alpine",
});
}
try {
const user = await redis.get(`user:${req.params.id}`);
if (user) {
res.json({ source: "redis", data: JSON.parse(user) });
} else {
res.status(404).json({ error: "User not found" });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get("/api/health", async (req, res) => {
if (!redisConnected) {
return res.status(503).json({
status: "unhealthy",
redis: "disconnected",
});
}
try {
await redis.ping();
res.json({
status: "healthy",
redis: "connected",
});
} catch (error) {
res.status(503).json({
status: "unhealthy",
redis: "error",
error: error.message,
});
}
});
app.get("/api/external", async (req, res) => {
console.log("[app] Attempting external API call...");
try {
const result = await fetchExternal("https://httpbin.org/get");
// Check if we're using a proxy (indicates fence is running)
const usingProxy = !!(process.env.HTTPS_PROXY || process.env.HTTP_PROXY);
res.json({
status: "success",
message: usingProxy
? "✓ Request allowed (httpbin.org is whitelisted)"
: "⚠️ No proxy detected - not running in fence",
proxy: usingProxy ? process.env.HTTPS_PROXY : null,
data: result,
});
} catch (error) {
res.json({
status: "blocked",
message: "✓ External call blocked by fence",
error: error.message,
});
}
});
// Startup
app.listen(PORT, () => {
console.log(`
╔═══════════════════════════════════════════════════════════╗
║ Dev Server Demo ║
╠═══════════════════════════════════════════════════════════╣
║ Server: http://localhost:${PORT}
║ Redis: localhost:6379 ║
╠═══════════════════════════════════════════════════════════╣
║ Endpoints: ║
║ GET / - API info ║
║ GET /api/users - List users from Redis ║
║ GET /api/users/:id - Get user by ID ║
║ GET /api/health - Health check ║
║ GET /api/external - Try external call (blocked) ║
╚═══════════════════════════════════════════════════════════╝
`);
});

View File

@@ -0,0 +1,9 @@
{
"network": {
"allowLocalBinding": true,
"allowLocalOutbound": true
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}

View File

@@ -0,0 +1,10 @@
{
"network": {
"allowLocalBinding": true,
"allowedDomains": ["httpbin.org"],
"allowLocalOutbound": false
},
"filesystem": {
"allowWrite": [".", "/tmp"]
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "dev-server-demo",
"version": "1.0.0",
"description": "Demo: Dev server with Redis in fence sandbox",
"type": "module",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2",
"ioredis": "^5.3.2",
"undici": "^6.19.2"
}
}

View File

@@ -0,0 +1,67 @@
# Filesystem Sandbox Demo
This demo shows how fence controls filesystem access with `allowWrite`, `denyWrite`, and `denyRead`.
## What it demonstrates
| Operation | Without Fence | With Fence |
|-----------|---------------|------------|
| Write to `./output/` | ✓ | ✓ (in allowWrite) |
| Write to `./` | ✓ | ✗ (not in allowWrite) |
| Write to `.env` | ✓ | ✗ (in denyWrite) |
| Write to `*.key` | ✓ | ✗ (in denyWrite) |
| Read `./demo.py` | ✓ | ✓ (allowed by default) |
| Read `/etc/shadow` | ✗ | ✗ (in denyRead) |
| Read `/etc/passwd` | ✓ | ✗ (in denyRead) |
## Run the demo
### Without fence (all writes succeed)
```bash
python demo.py
```
### With fence (unauthorized operations blocked)
```bash
fence --settings fence.json python demo.py
```
## Fence config
```json
{
"filesystem": {
"allowWrite": ["./output"],
"denyWrite": [".env", "*.key"],
"denyRead": ["/etc/shadow", "/etc/passwd"]
}
}
```
### How it works
1. **allowWrite** - Only paths listed here are writable. Everything else is read-only.
2. **denyWrite** - These paths are blocked even if they'd otherwise be allowed. Useful for protecting secrets.
3. **denyRead** - Block reads from sensitive system files.
## Key settings
| Setting | Default | Purpose |
|---------|---------|---------|
| `allowWrite` | `[]` (nothing) | Directories where writes are allowed |
| `denyWrite` | `[]` | Paths to block writes (overrides allowWrite) |
| `denyRead` | `[]` | Paths to block reads |
## Protected paths
Fence also automatically protects certain paths regardless of config:
- Shell configs: `.bashrc`, `.zshrc`, `.profile`
- Git hooks: `.git/hooks/*`
- Git config: `.gitconfig`
See [ARCHITECTURE.md](../../ARCHITECTURE.md) for the full list.

147
examples/02-filesystem/demo.py Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Filesystem Sandbox Demo
This script demonstrates fence's filesystem controls:
- allowWrite: Only specific directories are writable
- denyWrite: Block writes to sensitive files
- denyRead: Block reads from sensitive paths
Run WITHOUT fence to see all operations succeed.
Run WITH fence to see unauthorized operations blocked.
"""
import os
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
os.chdir(SCRIPT_DIR)
results = []
def log(operation, status, message):
icon = "" if status == "success" else ""
print(f"[{icon}] {operation}: {message}")
results.append({"operation": operation, "status": status, "message": message})
def try_write(filepath, content, description):
"""Attempt to write to a file."""
try:
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
log(description, "success", f"Wrote to {filepath}")
return True
except PermissionError:
log(description, "blocked", f"Permission denied: {filepath}")
return False
except OSError as e:
log(description, "blocked", f"OS error: {e}")
return False
def try_read(filepath, description):
"""Attempt to read from a file."""
try:
path = Path(filepath)
content = path.read_text()
log(description, "success", f"Read {len(content)} bytes from {filepath}")
return True
except PermissionError:
log(description, "blocked", f"Permission denied: {filepath}")
return False
except FileNotFoundError:
log(description, "skipped", f"File not found: {filepath}")
return False
except OSError as e:
log(description, "blocked", f"OS error: {e}")
return False
def cleanup():
"""Clean up test files."""
import shutil
try:
shutil.rmtree(SCRIPT_DIR / "output", ignore_errors=True)
(SCRIPT_DIR / "unauthorized.txt").unlink(missing_ok=True)
(SCRIPT_DIR / ".env").unlink(missing_ok=True)
(SCRIPT_DIR / "secrets.key").unlink(missing_ok=True)
except Exception:
pass
def main():
print("""
╔═══════════════════════════════════════════════════════════╗
║ Filesystem Sandbox Demo ║
╠═══════════════════════════════════════════════════════════╣
║ Tests fence's filesystem controls: ║
║ - allowWrite: Only ./output/ is writable ║
║ - denyWrite: .env and *.key files are protected ║
║ - denyRead: /etc/shadow is blocked ║
╚═══════════════════════════════════════════════════════════╝
""")
cleanup()
print("--- WRITE TESTS ---\n")
# Test 1: Write to allowed directory (should succeed)
try_write(
"output/data.txt",
"This file is in the allowed output directory.\n",
"Write to ./output/ (allowed)",
)
# Test 2: Write to project root (should fail with fence)
try_write(
"unauthorized.txt",
"This should not be writable.\n",
"Write to ./ (not in allowWrite)",
)
# Test 3: Write to .env file (should fail - denyWrite)
try_write(".env", "SECRET_KEY=stolen\n", "Write to .env (in denyWrite)")
# Test 4: Write to .key file (should fail - denyWrite pattern)
try_write(
"secrets.key", "-----BEGIN PRIVATE KEY-----\n", "Write to *.key (in denyWrite)"
)
print("\n--- READ TESTS ---\n")
# Test 5: Read from allowed file (should succeed)
try_read("demo.py", "Read ./demo.py (allowed)")
# Test 6: Read from /etc/shadow (should fail - denyRead)
try_read("/etc/shadow", "Read /etc/shadow (in denyRead)")
# Test 7: Read from /etc/passwd (should fail if in denyRead)
try_read("/etc/passwd", "Read /etc/passwd (in denyRead)")
# Summary
print("\n--- SUMMARY ---\n")
blocked = sum(1 for r in results if r["status"] == "blocked")
succeeded = sum(1 for r in results if r["status"] == "success")
skipped = sum(1 for r in results if r["status"] == "skipped")
if skipped > 0:
print(f"({skipped} test(s) skipped - file not found)")
if blocked > 0:
print(f"✅ Fence blocked {blocked} unauthorized operation(s)")
print(f"{succeeded} allowed operation(s) succeeded")
print("\nFilesystem sandbox is working!\n")
else:
print("⚠️ All operations succeeded - you are likely not running in fence")
print("Run with: fence --settings fence.json python demo.py\n")
cleanup()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,10 @@
{
"network": {
"allowedDomains": []
},
"filesystem": {
"allowWrite": ["./output"],
"denyWrite": [".env", "*.key"],
"denyRead": ["/etc/shadow", "/etc/passwd"]
}
}

15
examples/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Fence Examples
Runnable examples demonstrating `fence` capabilities.
If you're looking for copy/paste configs and "cookbook" workflows, also see:
- Config templates: [`docs/templates/`](../docs/templates/)
- Recipes for common workflows: [`docs/recipes/`](../docs/recipes/)
## Examples
| Example | What it demonstrates | How to run |
|--------|-----------------------|------------|
| **[01-dev-server](01-dev-server/README.md)** | Running a dev server in the sandbox, controlling **external domains** vs **localhost outbound** (Redis), and exposing an inbound port (`-p`) | `cd examples/01-dev-server && fence -p 3000 --settings fence-external-blocked.json npm start` |
| **[02-filesystem](02-filesystem/README.md)** | Filesystem controls: `allowWrite`, `denyWrite`, `denyRead` | `cd examples/02-filesystem && fence --settings fence.json python demo.py` |