From 8db245f56e87a164185ee02655cf4b5120d922cf Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 23 Dec 2025 18:43:07 -0800 Subject: [PATCH] Refactor and improve documentation, add examples --- ARCHITECTURE.md | 91 +------- README.md | 81 +------ docs/README.md | 51 +++++ docs/agents.md | 47 ++++ docs/concepts.md | 56 +++++ docs/configuration.md | 52 +++++ docs/quickstart.md | 128 +++++++++++ docs/recipes/README.md | 20 ++ docs/recipes/ci.md | 36 ++++ docs/recipes/git-clone.md | 32 +++ docs/recipes/npm-install.md | 37 ++++ docs/recipes/pip-poetry.md | 36 ++++ docs/security-model.md | 75 +++++++ docs/templates/README.md | 18 ++ docs/templates/agent-api-only.json | 8 + docs/templates/default-deny.json | 8 + docs/templates/local-dev-server.json | 9 + docs/templates/npm-install.json | 8 + docs/templates/pip-install.json | 8 + docs/templates/workspace-write.json | 5 + docs/troubleshooting.md | 79 +++++++ docs/why-fence.md | 40 ++++ examples/.gitignore | 23 ++ examples/01-dev-server/README.md | 89 ++++++++ examples/01-dev-server/app.js | 200 ++++++++++++++++++ .../01-dev-server/fence-external-blocked.json | 9 + .../01-dev-server/fence-external-only.json | 10 + examples/01-dev-server/package.json | 15 ++ examples/02-filesystem/README.md | 67 ++++++ examples/02-filesystem/demo.py | 147 +++++++++++++ examples/02-filesystem/fence.json | 10 + examples/README.md | 15 ++ 32 files changed, 1348 insertions(+), 162 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/agents.md create mode 100644 docs/concepts.md create mode 100644 docs/configuration.md create mode 100644 docs/quickstart.md create mode 100644 docs/recipes/README.md create mode 100644 docs/recipes/ci.md create mode 100644 docs/recipes/git-clone.md create mode 100644 docs/recipes/npm-install.md create mode 100644 docs/recipes/pip-poetry.md create mode 100644 docs/security-model.md create mode 100644 docs/templates/README.md create mode 100644 docs/templates/agent-api-only.json create mode 100644 docs/templates/default-deny.json create mode 100644 docs/templates/local-dev-server.json create mode 100644 docs/templates/npm-install.json create mode 100644 docs/templates/pip-install.json create mode 100644 docs/templates/workspace-write.json create mode 100644 docs/troubleshooting.md create mode 100644 docs/why-fence.md create mode 100644 examples/.gitignore create mode 100644 examples/01-dev-server/README.md create mode 100644 examples/01-dev-server/app.js create mode 100644 examples/01-dev-server/fence-external-blocked.json create mode 100644 examples/01-dev-server/fence-external-only.json create mode 100644 examples/01-dev-server/package.json create mode 100644 examples/02-filesystem/README.md create mode 100755 examples/02-filesystem/demo.py create mode 100644 examples/02-filesystem/fence.json create mode 100644 examples/README.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8500df6..10e52ee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -302,93 +302,4 @@ With `-m` on Linux, you only see proxy-level denials: ## Security Model -### How Each Layer Works - -#### 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. +See [`docs/security-model.md`](docs/security-model.md) for Fence's threat model, guarantees, and limitations. diff --git a/README.md b/README.md index b8c65d2..201f8f8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# fence +# Fence ![GitHub Release](https://img.shields.io/github/v/release/Use-Tusk/fence) 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 @@ -15,7 +15,7 @@ A Go implementation of process sandboxing with network and filesystem restrictio - **Cross-Platform**: macOS (sandbox-exec) and Linux (bubblewrap) - **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 @@ -44,51 +44,7 @@ fence -c "echo hello && ls" fence -d curl https://example.com ``` -## Configuration - -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) | +For a more detailed introduction, see the [Quickstart Guide](docs/quickstart.md). ## CLI Usage @@ -174,25 +130,12 @@ func main() { } ``` -## How It Works +## Documentation -### macOS (sandbox-exec) - -On macOS, fence uses Apple's `sandbox-exec` with a generated seatbelt profile that: - -- 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 +- [Documentation index](docs/) +- [Security model](docs/security-model.md) +- [Architecture](ARCHITECTURE.md) +- [Examples](examples/) 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) - `socat` (for network bridging) -Install on Ubuntu/Debian: - -```bash -apt install bubblewrap socat -``` - ## 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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c53e0a2 --- /dev/null +++ b/docs/README.md @@ -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 + +# Use custom config +fence --settings ./fence.json + +# Debug mode (verbose output) +fence -d + +# Monitor mode (show blocked requests) +fence -m + +# Expose port for servers +fence -p 3000 + +# Run shell command +fence -c "echo hello && ls" +``` diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..2a40b2f --- /dev/null +++ b/docs/agents.md @@ -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 +``` + +## 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. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..dd31d73 --- /dev/null +++ b/docs/concepts.md @@ -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). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0b145be --- /dev/null +++ b/docs/configuration.md @@ -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/) diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b495d60 --- /dev/null +++ b/docs/quickstart.md @@ -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). diff --git a/docs/recipes/README.md b/docs/recipes/README.md new file mode 100644 index 0000000..4055647 --- /dev/null +++ b/docs/recipes/README.md @@ -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) diff --git a/docs/recipes/ci.md b/docs/recipes/ci.md new file mode 100644 index 0000000..758bf67 --- /dev/null +++ b/docs/recipes/ci.md @@ -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 diff --git a/docs/recipes/git-clone.md b/docs/recipes/git-clone.md new file mode 100644 index 0000000..5cfb4e7 --- /dev/null +++ b/docs/recipes/git-clone.md @@ -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 +``` diff --git a/docs/recipes/npm-install.md b/docs/recipes/npm-install.md new file mode 100644 index 0000000..5864d20 --- /dev/null +++ b/docs/recipes/npm-install.md @@ -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. diff --git a/docs/recipes/pip-poetry.md b/docs/recipes/pip-poetry.md new file mode 100644 index 0000000..f9a2a1f --- /dev/null +++ b/docs/recipes/pip-poetry.md @@ -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. diff --git a/docs/security-model.md b/docs/security-model.md new file mode 100644 index 0000000..60be811 --- /dev/null +++ b/docs/security-model.md @@ -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). diff --git a/docs/templates/README.md b/docs/templates/README.md new file mode 100644 index 0000000..90f6dc2 --- /dev/null +++ b/docs/templates/README.md @@ -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 +``` diff --git a/docs/templates/agent-api-only.json b/docs/templates/agent-api-only.json new file mode 100644 index 0000000..3749c3e --- /dev/null +++ b/docs/templates/agent-api-only.json @@ -0,0 +1,8 @@ +{ + "network": { + "allowedDomains": ["api.openai.com", "api.anthropic.com"] + }, + "filesystem": { + "allowWrite": ["."] + } +} diff --git a/docs/templates/default-deny.json b/docs/templates/default-deny.json new file mode 100644 index 0000000..abe980c --- /dev/null +++ b/docs/templates/default-deny.json @@ -0,0 +1,8 @@ +{ + "network": { + "allowedDomains": [] + }, + "filesystem": { + "allowWrite": [] + } +} diff --git a/docs/templates/local-dev-server.json b/docs/templates/local-dev-server.json new file mode 100644 index 0000000..d04565a --- /dev/null +++ b/docs/templates/local-dev-server.json @@ -0,0 +1,9 @@ +{ + "network": { + "allowLocalBinding": true, + "allowLocalOutbound": true + }, + "filesystem": { + "allowWrite": [".", "/tmp"] + } +} diff --git a/docs/templates/npm-install.json b/docs/templates/npm-install.json new file mode 100644 index 0000000..456c4ec --- /dev/null +++ b/docs/templates/npm-install.json @@ -0,0 +1,8 @@ +{ + "network": { + "allowedDomains": ["registry.npmjs.org", "*.npmjs.org"] + }, + "filesystem": { + "allowWrite": [".", "node_modules", "/tmp"] + } +} diff --git a/docs/templates/pip-install.json b/docs/templates/pip-install.json new file mode 100644 index 0000000..1d63626 --- /dev/null +++ b/docs/templates/pip-install.json @@ -0,0 +1,8 @@ +{ + "network": { + "allowedDomains": ["pypi.org", "files.pythonhosted.org"] + }, + "filesystem": { + "allowWrite": [".", "/tmp"] + } +} diff --git a/docs/templates/workspace-write.json b/docs/templates/workspace-write.json new file mode 100644 index 0000000..8f821eb --- /dev/null +++ b/docs/templates/workspace-write.json @@ -0,0 +1,5 @@ +{ + "filesystem": { + "allowWrite": ["."] + } +} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..5a94d7d --- /dev/null +++ b/docs/troubleshooting.md @@ -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 ` +- Add the required destination(s) to `network.allowedDomains`. + +## "It works outside fence but not inside" + +Start with: + +- `fence -m ` to see what's being denied +- `fence -d ` 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 ` (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"] + } +} +``` diff --git a/docs/why-fence.md b/docs/why-fence.md new file mode 100644 index 0000000..3429018 --- /dev/null +++ b/docs/why-fence.md @@ -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). diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..7286482 --- /dev/null +++ b/examples/.gitignore @@ -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 + diff --git a/examples/01-dev-server/README.md b/examples/01-dev-server/README.md new file mode 100644 index 0000000..0057d6d --- /dev/null +++ b/examples/01-dev-server/README.md @@ -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. diff --git a/examples/01-dev-server/app.js b/examples/01-dev-server/app.js new file mode 100644 index 0000000..a84317e --- /dev/null +++ b/examples/01-dev-server/app.js @@ -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) ║ +╚═══════════════════════════════════════════════════════════╝ + `); +}); diff --git a/examples/01-dev-server/fence-external-blocked.json b/examples/01-dev-server/fence-external-blocked.json new file mode 100644 index 0000000..d04565a --- /dev/null +++ b/examples/01-dev-server/fence-external-blocked.json @@ -0,0 +1,9 @@ +{ + "network": { + "allowLocalBinding": true, + "allowLocalOutbound": true + }, + "filesystem": { + "allowWrite": [".", "/tmp"] + } +} diff --git a/examples/01-dev-server/fence-external-only.json b/examples/01-dev-server/fence-external-only.json new file mode 100644 index 0000000..dba527d --- /dev/null +++ b/examples/01-dev-server/fence-external-only.json @@ -0,0 +1,10 @@ +{ + "network": { + "allowLocalBinding": true, + "allowedDomains": ["httpbin.org"], + "allowLocalOutbound": false + }, + "filesystem": { + "allowWrite": [".", "/tmp"] + } +} diff --git a/examples/01-dev-server/package.json b/examples/01-dev-server/package.json new file mode 100644 index 0000000..dfc4441 --- /dev/null +++ b/examples/01-dev-server/package.json @@ -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" + } +} diff --git a/examples/02-filesystem/README.md b/examples/02-filesystem/README.md new file mode 100644 index 0000000..85ef3dc --- /dev/null +++ b/examples/02-filesystem/README.md @@ -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. diff --git a/examples/02-filesystem/demo.py b/examples/02-filesystem/demo.py new file mode 100755 index 0000000..746d43d --- /dev/null +++ b/examples/02-filesystem/demo.py @@ -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() diff --git a/examples/02-filesystem/fence.json b/examples/02-filesystem/fence.json new file mode 100644 index 0000000..430055e --- /dev/null +++ b/examples/02-filesystem/fence.json @@ -0,0 +1,10 @@ +{ + "network": { + "allowedDomains": [] + }, + "filesystem": { + "allowWrite": ["./output"], + "denyWrite": [".env", "*.key"], + "denyRead": ["/etc/shadow", "/etc/passwd"] + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..bec568c --- /dev/null +++ b/examples/README.md @@ -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` |