Compare commits
10 Commits
37b154bc94
...
5bb42db57a
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bb42db57a | |||
| dc5487c965 | |||
| da3a2ac3a4 | |||
| 481616455a | |||
| 9cb65151ee | |||
|
|
da5f61e390 | ||
|
|
b8b12ebe31 | ||
|
|
9db1ae8b54 | ||
|
|
7cc9fb3427 | ||
|
|
8630789c39 |
6
.github/workflows/benchmark.yml
vendored
6
.github/workflows/benchmark.yml
vendored
@@ -19,7 +19,7 @@ on:
|
||||
# paths:
|
||||
# - "internal/sandbox/**"
|
||||
# - "internal/proxy/**"
|
||||
# - "cmd/fence/**"
|
||||
# - "cmd/greywall/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Install benchstat
|
||||
run: go install golang.org/x/perf/cmd/benchstat@latest
|
||||
|
||||
- name: Build fence
|
||||
- name: Build greywall
|
||||
run: make build-ci
|
||||
|
||||
- name: Run Go microbenchmarks
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
- name: Install benchstat
|
||||
run: go install golang.org/x/perf/cmd/benchstat@latest
|
||||
|
||||
- name: Build fence
|
||||
- name: Build greywall
|
||||
run: make build-ci
|
||||
|
||||
- name: Run Go microbenchmarks
|
||||
|
||||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -115,7 +115,7 @@ jobs:
|
||||
run: make build-ci
|
||||
|
||||
- name: Run smoke tests
|
||||
run: FENCE_TEST_NETWORK=1 ./scripts/smoke_test.sh ./fence
|
||||
run: GREYWALL_TEST_NETWORK=1 ./scripts/smoke_test.sh ./greywall
|
||||
|
||||
test-macos:
|
||||
name: Test (macOS)
|
||||
@@ -160,4 +160,4 @@ jobs:
|
||||
run: make build-ci
|
||||
|
||||
- name: Run smoke tests
|
||||
run: FENCE_TEST_NETWORK=1 ./scripts/smoke_test.sh ./fence
|
||||
run: GREYWALL_TEST_NETWORK=1 ./scripts/smoke_test.sh ./greywall
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
{
|
||||
"version": "${{ github.ref_name }}",
|
||||
"published_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"url": "https://github.com/Use-Tusk/fence/releases/tag/${{ github.ref_name }}"
|
||||
"url": "https://gitea.app.monadical.io/monadical/greywall/releases/tag/${{ github.ref_name }}"
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# Binary (only at root, not cmd/fence or pkg/fence)
|
||||
/fence
|
||||
/fence_*
|
||||
/fence-*
|
||||
# Binary (only at root, not cmd/greywall or pkg/greywall)
|
||||
/greywall
|
||||
/greywall_*
|
||||
/greywall-*
|
||||
|
||||
# Tar archives
|
||||
*.tar.gz
|
||||
@@ -29,3 +29,6 @@ coverage.out
|
||||
cpu.out
|
||||
mem.out
|
||||
|
||||
# Embedded binaries (downloaded at build time)
|
||||
internal/sandbox/bin/tun2socks-*
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ linters-settings:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(github.com/Use-Tusk/fence)
|
||||
- prefix(gitea.app.monadical.io/monadical/greywall)
|
||||
gofmt:
|
||||
simplify: true
|
||||
goimports:
|
||||
local-prefixes: github.com/Use-Tusk/fence
|
||||
local-prefixes: gitea.app.monadical.io/monadical/greywall
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- singleCaseSwitch
|
||||
|
||||
@@ -18,8 +18,8 @@ builds:
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.buildTime={{.Date}}
|
||||
- -X main.gitCommit={{.Commit}}
|
||||
binary: fence
|
||||
main: ./cmd/fence
|
||||
binary: greywall
|
||||
main: ./cmd/greywall
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
@@ -77,7 +77,7 @@ changelog:
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: Use-Tusk
|
||||
name: fence
|
||||
owner: monadical
|
||||
name: greywall
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
Fence restricts network, filesystem, and command access for arbitrary commands. It works by:
|
||||
Greywall restricts network, filesystem, and command access for arbitrary commands. It works by:
|
||||
|
||||
1. **Blocking commands** via configurable deny/allow lists before execution
|
||||
2. **Intercepting network traffic** via HTTP/SOCKS5 proxies that filter by domain
|
||||
@@ -9,7 +9,7 @@ Fence restricts network, filesystem, and command access for arbitrary commands.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Fence
|
||||
subgraph Greywall
|
||||
Config["Config<br/>(JSON)"]
|
||||
Manager
|
||||
CmdCheck["Command<br/>Blocking"]
|
||||
@@ -30,8 +30,8 @@ flowchart TB
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
fence/
|
||||
├── cmd/fence/ # CLI entry point
|
||||
greywall/
|
||||
├── cmd/greywall/ # CLI entry point
|
||||
│ └── main.go # Includes --landlock-apply wrapper mode
|
||||
├── internal/ # Private implementation
|
||||
│ ├── config/ # Configuration loading/validation
|
||||
@@ -52,8 +52,8 @@ fence/
|
||||
│ ├── dangerous.go # Protected file/directory lists
|
||||
│ ├── shell.go # Shell quoting utilities
|
||||
│ └── utils.go # Path normalization
|
||||
└── pkg/fence/ # Public Go API
|
||||
└── fence.go
|
||||
└── pkg/greywall/ # Public Go API
|
||||
└── greywall.go
|
||||
```
|
||||
|
||||
## Core Components
|
||||
@@ -71,7 +71,7 @@ type Config struct {
|
||||
}
|
||||
```
|
||||
|
||||
- Loads from XDG config dir (`~/.config/fence/fence.json` or `~/Library/Application Support/fence/fence.json`) or custom path
|
||||
- Loads from XDG config dir (`~/.config/greywall/greywall.json` or `~/Library/Application Support/greywall/greywall.json`) or custom path
|
||||
- Falls back to restrictive defaults (block all network, default command deny list)
|
||||
- Validates paths and normalizes them
|
||||
|
||||
@@ -181,7 +181,7 @@ flowchart TB
|
||||
SOCKS["SOCKS Proxy<br/>:random"]
|
||||
HSOCAT["socat<br/>(HTTP bridge)"]
|
||||
SSOCAT["socat<br/>(SOCKS bridge)"]
|
||||
USOCK["Unix Sockets<br/>/tmp/fence-*.sock"]
|
||||
USOCK["Unix Sockets<br/>/tmp/greywall-*.sock"]
|
||||
end
|
||||
|
||||
subgraph Sandbox ["Sandbox (bwrap --unshare-net)"]
|
||||
@@ -221,7 +221,7 @@ flowchart TB
|
||||
|
||||
subgraph Host
|
||||
HSOCAT["socat<br/>TCP-LISTEN:8888"]
|
||||
USOCK["Unix Socket<br/>/tmp/fence-rev-8888-*.sock"]
|
||||
USOCK["Unix Socket<br/>/tmp/greywall-rev-8888-*.sock"]
|
||||
end
|
||||
|
||||
subgraph Sandbox
|
||||
@@ -286,7 +286,7 @@ flowchart TD
|
||||
|
||||
### Linux Security Layers
|
||||
|
||||
On Linux, fence uses multiple security layers with graceful fallback:
|
||||
On Linux, greywall uses multiple security layers with graceful fallback:
|
||||
|
||||
1. bubblewrap (core isolation via Linux namespaces)
|
||||
2. seccomp (syscall filtering)
|
||||
@@ -306,15 +306,15 @@ The `-m` (monitor) flag enables real-time visibility into blocked operations. Th
|
||||
|
||||
| Prefix | Source | Description |
|
||||
|--------|--------|-------------|
|
||||
| `[fence:http]` | Both | HTTP/HTTPS proxy (blocked requests only in monitor mode) |
|
||||
| `[fence:socks]` | Both | SOCKS5 proxy (blocked requests only in monitor mode) |
|
||||
| `[fence:logstream]` | macOS only | Kernel-level sandbox violations from `log stream` |
|
||||
| `[fence:ebpf]` | Linux only | Filesystem/syscall failures (requires CAP_BPF or root) |
|
||||
| `[fence:filter]` | Both | Domain filter rule matches (debug mode only) |
|
||||
| `[greywall:http]` | Both | HTTP/HTTPS proxy (blocked requests only in monitor mode) |
|
||||
| `[greywall:socks]` | Both | SOCKS5 proxy (blocked requests only in monitor mode) |
|
||||
| `[greywall:logstream]` | macOS only | Kernel-level sandbox violations from `log stream` |
|
||||
| `[greywall:ebpf]` | Linux only | Filesystem/syscall failures (requires CAP_BPF or root) |
|
||||
| `[greywall:filter]` | Both | Domain filter rule matches (debug mode only) |
|
||||
|
||||
### macOS Log Stream
|
||||
|
||||
On macOS, fence spawns `log stream` with a predicate to capture sandbox violations:
|
||||
On macOS, greywall spawns `log stream` with a predicate to capture sandbox violations:
|
||||
|
||||
```bash
|
||||
log stream --predicate 'eventMessage ENDSWITH "_SBX"' --style compact
|
||||
@@ -344,4 +344,4 @@ Filtered out (too noisy):
|
||||
|
||||
## Security Model
|
||||
|
||||
See [`docs/security-model.md`](docs/security-model.md) for Fence's threat model, guarantees, and limitations.
|
||||
See [`docs/security-model.md`](docs/security-model.md) for Greywall's threat model, guarantees, and limitations.
|
||||
|
||||
79
CLAUDE.md
Normal file
79
CLAUDE.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Greywall
|
||||
|
||||
Sandboxing layer for GreyHaven that wraps commands in restrictive sandbox environments. Blocks network access by default (allowlist-based), restricts filesystem operations, and controls command execution. Supports macOS (sandbox-exec/Seatbelt) and Linux (bubblewrap + seccomp/Landlock/eBPF).
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
make setup # install deps + lint tools (first time)
|
||||
make build # compile binary (downloads tun2socks)
|
||||
make run # build and run
|
||||
./greywall --help # CLI usage
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
make test # all unit + integration tests
|
||||
make test-ci # with coverage and race detection (-race -coverprofile)
|
||||
GREYWALL_TEST_NETWORK=1 ./scripts/smoke_test.sh ./greywall # smoke tests
|
||||
```
|
||||
|
||||
## Lint & Format
|
||||
|
||||
```bash
|
||||
make fmt # format with gofumpt
|
||||
make lint # golangci-lint (staticcheck, errcheck, gosec, govet, revive, gofumpt, misspell, etc.)
|
||||
```
|
||||
|
||||
Always run `make fmt && make lint` before committing.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cmd/greywall/ CLI entry point
|
||||
internal/
|
||||
config/ Configuration loading & validation
|
||||
platform/ OS detection
|
||||
sandbox/ Platform-specific sandboxing (~7k lines)
|
||||
manager.go Sandbox lifecycle orchestration
|
||||
command.go Command blocking/allow lists
|
||||
linux.go bubblewrap + bridges (ProxyBridge, DnsBridge)
|
||||
macos.go sandbox-exec Seatbelt profiles
|
||||
linux_seccomp.go Seccomp BPF syscall filtering
|
||||
linux_landlock.go Landlock filesystem control
|
||||
linux_ebpf.go eBPF violation monitoring
|
||||
sanitize.go Environment variable hardening
|
||||
dangerous.go Protected files/dirs lists
|
||||
pkg/greywall/ Public Go API
|
||||
docs/ Full documentation
|
||||
scripts/ Smoke tests, benchmarks, release
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **Language:** Go 1.25+
|
||||
- **Formatter:** `gofumpt` (enforced in CI)
|
||||
- **Linter:** `golangci-lint` v1.64.8 (config in `.golangci.yml`)
|
||||
- **Import order:** stdlib, third-party, local (`gitea.app.monadical.io/monadical/greywall`)
|
||||
- **Platform code:** build tags (`//go:build linux`, `//go:build darwin`) with `*_stub.go` for unsupported platforms
|
||||
- **Error handling:** custom error types (e.g., `CommandBlockedError`)
|
||||
- **Logging:** stderr with `[greywall:component]` prefixes
|
||||
- **Config:** JSON with comments (via `tidwall/jsonc`), optional pointer fields for three-state booleans
|
||||
|
||||
## Dependencies
|
||||
|
||||
4 direct deps: `doublestar` (glob matching), `cobra` (CLI), `jsonc` (config parsing), `golang.org/x/sys`.
|
||||
|
||||
Runtime (Linux): `bubblewrap`, `socat`, embedded `tun2socks` v2.5.2.
|
||||
|
||||
## CI
|
||||
|
||||
GitHub Actions workflows: `main.yml` (build/lint/test on Linux+macOS), `release.yml` (GoReleaser + SLSA provenance), `benchmark.yml`.
|
||||
|
||||
## Release
|
||||
|
||||
```bash
|
||||
make release # patch (v0.0.X)
|
||||
make release-minor # minor (v0.X.0)
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for helping improve `fence`!
|
||||
Thanks for helping improve `greywall`!
|
||||
|
||||
If you have any questions, feel free to open an issue.
|
||||
|
||||
@@ -12,11 +12,11 @@ If you have any questions, feel free to open an issue.
|
||||
- Clone and prepare:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Use-Tusk/fence
|
||||
cd fence
|
||||
git clone https://gitea.app.monadical.io/monadical/greywall
|
||||
cd greywall
|
||||
make setup # Install deps and lint tools
|
||||
make build # Build the binary
|
||||
./fence --help
|
||||
./greywall --help
|
||||
```
|
||||
|
||||
## Dev workflow
|
||||
@@ -25,7 +25,7 @@ Common targets:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make build` | Build the binary (`./fence`) |
|
||||
| `make build` | Build the binary (`./greywall`) |
|
||||
| `make run` | Build and run |
|
||||
| `make test` | Run tests |
|
||||
| `make test-ci` | Run tests with coverage |
|
||||
@@ -63,14 +63,14 @@ make test-ci
|
||||
|
||||
```bash
|
||||
# Test blocked network request
|
||||
./fence curl https://example.com
|
||||
./greywall curl https://example.com
|
||||
|
||||
# Test with allowed domain
|
||||
echo '{"network":{"allowedDomains":["example.com"]}}' > /tmp/test.json
|
||||
./fence -s /tmp/test.json curl https://example.com
|
||||
./greywall -s /tmp/test.json curl https://example.com
|
||||
|
||||
# Test monitor mode
|
||||
./fence -m -c "touch /etc/test"
|
||||
./greywall -m -c "touch /etc/test"
|
||||
```
|
||||
|
||||
### Testing on Linux
|
||||
@@ -82,7 +82,7 @@ Requires `bubblewrap` and `socat`:
|
||||
sudo apt install bubblewrap socat
|
||||
|
||||
# Test in Colima or VM
|
||||
./fence curl https://example.com
|
||||
./greywall curl https://example.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -187,6 +187,7 @@
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Tusk AI, Inc
|
||||
Copyright 2026 GreyHaven
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
72
Makefile
72
Makefile
@@ -3,90 +3,109 @@ GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOMOD=$(GOCMD) mod
|
||||
BINARY_NAME=fence
|
||||
BINARY_NAME=greywall
|
||||
BINARY_UNIX=$(BINARY_NAME)_unix
|
||||
TUN2SOCKS_VERSION=v2.5.2
|
||||
TUN2SOCKS_BIN_DIR=internal/sandbox/bin
|
||||
|
||||
.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor help
|
||||
.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor download-tun2socks help
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
@echo "🔨 Building $(BINARY_NAME)..."
|
||||
$(GOBUILD) -o $(BINARY_NAME) -v ./cmd/fence
|
||||
download-tun2socks:
|
||||
@echo "Downloading tun2socks $(TUN2SOCKS_VERSION)..."
|
||||
@mkdir -p $(TUN2SOCKS_BIN_DIR)
|
||||
@curl -sL "https://github.com/xjasonlyu/tun2socks/releases/download/$(TUN2SOCKS_VERSION)/tun2socks-linux-amd64.zip" -o /tmp/tun2socks-linux-amd64.zip
|
||||
@unzip -o -q /tmp/tun2socks-linux-amd64.zip -d /tmp/tun2socks-amd64
|
||||
@mv /tmp/tun2socks-amd64/tun2socks-linux-amd64 $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-amd64
|
||||
@chmod +x $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-amd64
|
||||
@rm -rf /tmp/tun2socks-linux-amd64.zip /tmp/tun2socks-amd64
|
||||
@curl -sL "https://github.com/xjasonlyu/tun2socks/releases/download/$(TUN2SOCKS_VERSION)/tun2socks-linux-arm64.zip" -o /tmp/tun2socks-linux-arm64.zip
|
||||
@unzip -o -q /tmp/tun2socks-linux-arm64.zip -d /tmp/tun2socks-arm64
|
||||
@mv /tmp/tun2socks-arm64/tun2socks-linux-arm64 $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-arm64
|
||||
@chmod +x $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-arm64
|
||||
@rm -rf /tmp/tun2socks-linux-arm64.zip /tmp/tun2socks-arm64
|
||||
@echo "tun2socks binaries downloaded to $(TUN2SOCKS_BIN_DIR)/"
|
||||
|
||||
build-ci:
|
||||
@echo "🏗️ CI: Building $(BINARY_NAME) with version info..."
|
||||
build: download-tun2socks
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
$(GOBUILD) -o $(BINARY_NAME) -v ./cmd/greywall
|
||||
|
||||
build-ci: download-tun2socks
|
||||
@echo "CI: Building $(BINARY_NAME) with version info..."
|
||||
$(eval VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev"))
|
||||
$(eval BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ'))
|
||||
$(eval GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown"))
|
||||
$(GOBUILD) -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)" -o $(BINARY_NAME) -v ./cmd/fence
|
||||
$(GOBUILD) -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)" -o $(BINARY_NAME) -v ./cmd/greywall
|
||||
|
||||
test:
|
||||
@echo "🧪 Running tests..."
|
||||
@echo "Running tests..."
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
test-ci:
|
||||
@echo "🧪 CI: Running tests with coverage..."
|
||||
@echo "CI: Running tests with coverage..."
|
||||
$(GOTEST) -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
clean:
|
||||
@echo "🧹 Cleaning..."
|
||||
@echo "Cleaning..."
|
||||
$(GOCLEAN)
|
||||
rm -f $(BINARY_NAME)
|
||||
rm -f $(BINARY_UNIX)
|
||||
rm -f coverage.out
|
||||
rm -f $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-*
|
||||
|
||||
deps:
|
||||
@echo "📦 Downloading dependencies..."
|
||||
@echo "Downloading dependencies..."
|
||||
$(GOMOD) download
|
||||
$(GOMOD) tidy
|
||||
|
||||
build-linux:
|
||||
@echo "🐧 Building for Linux..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/fence
|
||||
build-linux: download-tun2socks
|
||||
@echo "Building for Linux..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/greywall
|
||||
|
||||
build-darwin:
|
||||
@echo "🍎 Building for macOS..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_NAME)_darwin -v ./cmd/fence
|
||||
@echo "Building for macOS..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_NAME)_darwin -v ./cmd/greywall
|
||||
|
||||
install-lint-tools:
|
||||
@echo "📦 Installing linting tools..."
|
||||
@echo "Installing linting tools..."
|
||||
go install mvdan.cc/gofumpt@latest
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
@echo "✅ Linting tools installed"
|
||||
@echo "Linting tools installed"
|
||||
|
||||
setup: deps install-lint-tools
|
||||
@echo "✅ Development environment ready"
|
||||
@echo "Development environment ready"
|
||||
|
||||
setup-ci: deps install-lint-tools
|
||||
@echo "✅ CI environment ready"
|
||||
@echo "CI environment ready"
|
||||
|
||||
run: build
|
||||
./$(BINARY_NAME)
|
||||
|
||||
fmt:
|
||||
@echo "📝 Formatting code..."
|
||||
@echo "Formatting code..."
|
||||
gofumpt -w .
|
||||
|
||||
lint:
|
||||
@echo "🔍 Linting code..."
|
||||
@echo "Linting code..."
|
||||
golangci-lint run --allow-parallel-runners
|
||||
|
||||
release:
|
||||
@echo "🚀 Creating patch release..."
|
||||
@echo "Creating patch release..."
|
||||
./scripts/release.sh patch
|
||||
|
||||
release-minor:
|
||||
@echo "🚀 Creating minor release..."
|
||||
@echo "Creating minor release..."
|
||||
./scripts/release.sh minor
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " all - build (default)"
|
||||
@echo " build - Build the binary"
|
||||
@echo " build - Build the binary (downloads tun2socks if needed)"
|
||||
@echo " build-ci - Build for CI with version info"
|
||||
@echo " build-linux - Build for Linux"
|
||||
@echo " build-darwin - Build for macOS"
|
||||
@echo " download-tun2socks - Download tun2socks binaries for embedding"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-ci - Run tests for CI with coverage"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@@ -100,4 +119,3 @@ help:
|
||||
@echo " release - Create patch release (v0.0.X)"
|
||||
@echo " release-minor - Create minor release (v0.X.0)"
|
||||
@echo " help - Show this help"
|
||||
|
||||
|
||||
50
README.md
50
README.md
@@ -1,32 +1,30 @@
|
||||

|
||||

|
||||
|
||||
<div align="center">
|
||||
# Greywall
|
||||
|
||||

|
||||
**The sandboxing layer of the GreyHaven platform.**
|
||||
|
||||
</div>
|
||||
|
||||
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.
|
||||
Greywall wraps commands in a sandbox that blocks network access by default and restricts filesystem operations. It is the core sandboxing component of the GreyHaven platform, providing defense-in-depth for running untrusted code.
|
||||
|
||||
```bash
|
||||
# Block all network access (default)
|
||||
fence curl https://example.com # → 403 Forbidden
|
||||
greywall curl https://example.com # → 403 Forbidden
|
||||
|
||||
# Allow specific domains
|
||||
fence -t code npm install # → uses 'code' template with npm/pypi/etc allowed
|
||||
greywall -t code npm install # → uses 'code' template with npm/pypi/etc allowed
|
||||
|
||||
# Block dangerous commands
|
||||
fence -c "rm -rf /" # → blocked by command deny rules
|
||||
greywall -c "rm -rf /" # → blocked by command deny rules
|
||||
```
|
||||
|
||||
You can also think of Fence as a permission manager for your CLI agents. **Fence works with popular coding agents like Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, Factory (Droid) CLI, etc.** See [agents.md](./docs/agents.md) for more details.
|
||||
Greywall also works as a permission manager for CLI agents. **Greywall works with popular coding agents like Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, Factory (Droid) CLI, etc.** See [agents.md](./docs/agents.md) for more details.
|
||||
|
||||
## Install
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Use-Tusk/fence/main/install.sh | sh
|
||||
curl -fsSL https://gitea.app.monadical.io/monadical/greywall/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -35,15 +33,15 @@ curl -fsSL https://raw.githubusercontent.com/Use-Tusk/fence/main/install.sh | sh
|
||||
**Go install:**
|
||||
|
||||
```bash
|
||||
go install github.com/Use-Tusk/fence/cmd/fence@latest
|
||||
go install gitea.app.monadical.io/monadical/greywall/cmd/greywall@latest
|
||||
```
|
||||
|
||||
**Build from source:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Use-Tusk/fence
|
||||
cd fence
|
||||
go build -o fence ./cmd/fence
|
||||
git clone https://gitea.app.monadical.io/monadical/greywall
|
||||
cd greywall
|
||||
go build -o greywall ./cmd/greywall
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -60,27 +58,27 @@ go build -o fence ./cmd/fence
|
||||
|
||||
```bash
|
||||
# Run command with all network blocked (no domains allowed by default)
|
||||
fence curl https://example.com
|
||||
greywall curl https://example.com
|
||||
|
||||
# Run with shell expansion
|
||||
fence -c "echo hello && ls"
|
||||
greywall -c "echo hello && ls"
|
||||
|
||||
# Enable debug logging
|
||||
fence -d curl https://example.com
|
||||
greywall -d curl https://example.com
|
||||
|
||||
# Use a template
|
||||
fence -t code -- claude # Runs Claude Code using `code` template config
|
||||
greywall -t code -- claude # Runs Claude Code using `code` template config
|
||||
|
||||
# Monitor mode (shows violations)
|
||||
fence -m npm install
|
||||
greywall -m npm install
|
||||
|
||||
# Show all commands and options
|
||||
fence --help
|
||||
greywall --help
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Fence reads from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS).
|
||||
Greywall reads from `~/.config/greywall/greywall.json` by default (or `~/Library/Application Support/greywall/greywall.json` on macOS).
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -91,12 +89,12 @@ Fence reads from `~/.config/fence/fence.json` by default (or `~/Library/Applicat
|
||||
}
|
||||
```
|
||||
|
||||
Use `fence --settings ./custom.json` to specify a different config.
|
||||
Use `greywall --settings ./custom.json` to specify a different config.
|
||||
|
||||
### Import from Claude Code
|
||||
|
||||
```bash
|
||||
fence import --claude --save
|
||||
greywall import --claude --save
|
||||
```
|
||||
|
||||
## Features
|
||||
@@ -109,7 +107,7 @@ fence import --claude --save
|
||||
- **Violation monitoring** - Real-time logging of blocked requests (`-m`)
|
||||
- **Cross-platform** - macOS (sandbox-exec) + Linux (bubblewrap)
|
||||
|
||||
Fence can be used as a Go package or CLI tool.
|
||||
Greywall can be used as a Go package or CLI tool.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -123,4 +121,6 @@ Fence can be used as a Go package or CLI tool.
|
||||
|
||||
## Attribution
|
||||
|
||||
Greywall is based on [Fence](https://github.com/Use-Tusk/fence) by Use-Tusk.
|
||||
|
||||
Inspired by Anthropic's [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a security issue, please email [support@usetusk.ai](support@usetusk.ai) with a description of the issue, the steps you took to create the issue, affected versions, and, if known, mitigations for the issue. This project follows a 90 day disclosure timeline.
|
||||
To report a security issue, please email [support@greyhaven.co](mailto:support@greyhaven.co) with a description of the issue, the steps you took to create the issue, affected versions, and, if known, mitigations for the issue. This project follows a 90 day disclosure timeline.
|
||||
|
||||
This security policy applies to the latest version of our main branch.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 407 KiB After Width: | Height: | Size: 407 KiB |
@@ -1,577 +0,0 @@
|
||||
// Package main implements the fence CLI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/Use-Tusk/fence/internal/importer"
|
||||
"github.com/Use-Tusk/fence/internal/platform"
|
||||
"github.com/Use-Tusk/fence/internal/sandbox"
|
||||
"github.com/Use-Tusk/fence/internal/templates"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Build-time variables (set via -ldflags)
|
||||
var (
|
||||
version = "dev"
|
||||
buildTime = "unknown"
|
||||
gitCommit = "unknown"
|
||||
)
|
||||
|
||||
var (
|
||||
debug bool
|
||||
monitor bool
|
||||
settingsPath string
|
||||
templateName string
|
||||
listTemplates bool
|
||||
cmdString string
|
||||
exposePorts []string
|
||||
exitCode int
|
||||
showVersion bool
|
||||
linuxFeatures bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for internal --landlock-apply mode (used inside sandbox)
|
||||
// This must be checked before cobra to avoid flag conflicts
|
||||
if len(os.Args) >= 2 && os.Args[1] == "--landlock-apply" {
|
||||
runLandlockWrapper()
|
||||
return
|
||||
}
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "fence [flags] -- [command...]",
|
||||
Short: "Run commands in a sandbox with network and filesystem restrictions",
|
||||
Long: `fence is a command-line tool that runs commands in a sandboxed environment
|
||||
with network and filesystem restrictions.
|
||||
|
||||
By default, all network access is blocked. Configure allowed domains in
|
||||
~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS)
|
||||
or pass a settings file with --settings, or use a built-in template with --template.
|
||||
|
||||
Examples:
|
||||
fence curl https://example.com # Will be blocked (no domains allowed)
|
||||
fence -- curl -s https://example.com # Use -- to separate fence flags from command
|
||||
fence -c "echo hello && ls" # Run with shell expansion
|
||||
fence --settings config.json npm install
|
||||
fence -t npm-install npm install # Use built-in npm-install template
|
||||
fence -t ai-coding-agents -- agent-cmd # Use AI coding agents template
|
||||
fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections
|
||||
fence --list-templates # Show available built-in templates
|
||||
|
||||
Configuration file format:
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": ["github.com", "*.npmjs.org"],
|
||||
"deniedDomains": []
|
||||
},
|
||||
"filesystem": {
|
||||
"denyRead": [],
|
||||
"allowWrite": ["."],
|
||||
"denyWrite": []
|
||||
},
|
||||
"command": {
|
||||
"deny": ["git push", "npm publish"]
|
||||
}
|
||||
}`,
|
||||
RunE: runCommand,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
}
|
||||
|
||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)")
|
||||
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
|
||||
rootCmd.Flags().StringVarP(&templateName, "template", "t", "", "Use built-in template (e.g., ai-coding-agents, npm-install)")
|
||||
rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates")
|
||||
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
|
||||
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
|
||||
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
|
||||
rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit")
|
||||
|
||||
rootCmd.Flags().SetInterspersed(true)
|
||||
|
||||
rootCmd.AddCommand(newImportCmd())
|
||||
rootCmd.AddCommand(newCompletionCmd(rootCmd))
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
exitCode = 1
|
||||
}
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func runCommand(cmd *cobra.Command, args []string) error {
|
||||
if showVersion {
|
||||
fmt.Printf("fence - lightweight, container-free sandbox for running untrusted commands\n")
|
||||
fmt.Printf(" Version: %s\n", version)
|
||||
fmt.Printf(" Built: %s\n", buildTime)
|
||||
fmt.Printf(" Commit: %s\n", gitCommit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if linuxFeatures {
|
||||
sandbox.PrintLinuxFeatures()
|
||||
return nil
|
||||
}
|
||||
|
||||
if listTemplates {
|
||||
printTemplates()
|
||||
return nil
|
||||
}
|
||||
|
||||
var command string
|
||||
switch {
|
||||
case cmdString != "":
|
||||
command = cmdString
|
||||
case len(args) > 0:
|
||||
command = strings.Join(args, " ")
|
||||
default:
|
||||
return fmt.Errorf("no command specified. Use -c <command> or provide command arguments")
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Command: %s\n", command)
|
||||
}
|
||||
|
||||
var ports []int
|
||||
for _, p := range exposePorts {
|
||||
port, err := strconv.Atoi(p)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port: %s", p)
|
||||
}
|
||||
ports = append(ports, port)
|
||||
}
|
||||
|
||||
if debug && len(ports) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports)
|
||||
}
|
||||
|
||||
// Load config: template > settings file > default path
|
||||
var cfg *config.Config
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case templateName != "":
|
||||
cfg, err = templates.Load(templateName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load template: %w\nUse --list-templates to see available templates", err)
|
||||
}
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Using template: %s\n", templateName)
|
||||
}
|
||||
case settingsPath != "":
|
||||
cfg, err = config.Load(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
absPath, _ := filepath.Abs(settingsPath)
|
||||
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(absPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve extends: %w", err)
|
||||
}
|
||||
default:
|
||||
configPath := config.DefaultConfigPath()
|
||||
cfg, err = config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
|
||||
}
|
||||
cfg = config.Default()
|
||||
} else {
|
||||
cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve extends: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
manager := sandbox.NewManager(cfg, debug, monitor)
|
||||
manager.SetExposedPorts(ports)
|
||||
defer manager.Cleanup()
|
||||
|
||||
if err := manager.Initialize(); err != nil {
|
||||
return fmt.Errorf("failed to initialize sandbox: %w", err)
|
||||
}
|
||||
|
||||
var logMonitor *sandbox.LogMonitor
|
||||
if monitor {
|
||||
logMonitor = sandbox.NewLogMonitor(sandbox.GetSessionSuffix())
|
||||
if logMonitor != nil {
|
||||
if err := logMonitor.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Warning: failed to start log monitor: %v\n", err)
|
||||
} else {
|
||||
defer logMonitor.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sandboxedCommand, err := manager.WrapCommand(command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wrap command: %w", err)
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Sandboxed command: %s\n", sandboxedCommand)
|
||||
}
|
||||
|
||||
hardenedEnv := sandbox.GetHardenedEnv()
|
||||
if debug {
|
||||
if stripped := sandbox.GetStrippedEnvVars(os.Environ()); len(stripped) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[fence] Stripped dangerous env vars: %v\n", stripped)
|
||||
}
|
||||
}
|
||||
|
||||
execCmd := exec.Command("sh", "-c", sandboxedCommand) //nolint:gosec // sandboxedCommand is constructed from user input - intentional
|
||||
execCmd.Env = hardenedEnv
|
||||
execCmd.Stdin = os.Stdin
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Start the command (non-blocking) so we can get the PID
|
||||
if err := execCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
// Start Linux monitors (eBPF tracing for filesystem violations)
|
||||
var linuxMonitors *sandbox.LinuxMonitors
|
||||
if monitor && execCmd.Process != nil {
|
||||
linuxMonitors, _ = sandbox.StartLinuxMonitor(execCmd.Process.Pid, sandbox.LinuxSandboxOptions{
|
||||
Monitor: true,
|
||||
Debug: debug,
|
||||
UseEBPF: true,
|
||||
})
|
||||
if linuxMonitors != nil {
|
||||
defer linuxMonitors.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Landlock is NOT applied here because:
|
||||
// 1. The sandboxed command is already running (Landlock only affects future children)
|
||||
// 2. Proper Landlock integration requires applying restrictions inside the sandbox
|
||||
// For now, filesystem isolation relies on bwrap mount namespaces.
|
||||
// Landlock code exists for future integration (e.g., via a wrapper binary).
|
||||
|
||||
go func() {
|
||||
sigCount := 0
|
||||
for sig := range sigChan {
|
||||
sigCount++
|
||||
if execCmd.Process == nil {
|
||||
continue
|
||||
}
|
||||
// First signal: graceful termination; second signal: force kill
|
||||
if sigCount >= 2 {
|
||||
_ = execCmd.Process.Kill()
|
||||
} else {
|
||||
_ = execCmd.Process.Signal(sig)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for command to finish
|
||||
if err := execCmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
// Set exit code but don't os.Exit() here - let deferred cleanup run
|
||||
exitCode = exitErr.ExitCode()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newImportCmd creates the import subcommand.
|
||||
func newImportCmd() *cobra.Command {
|
||||
var (
|
||||
claudeMode bool
|
||||
inputFile string
|
||||
outputFile string
|
||||
saveFlag bool
|
||||
forceFlag bool
|
||||
extendTmpl string
|
||||
noExtend bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import settings from other tools",
|
||||
Long: `Import permission settings from other tools and convert them to fence config.
|
||||
|
||||
Currently supported sources:
|
||||
--claude Import from Claude Code settings
|
||||
|
||||
By default, imports extend the "code" template which provides sensible defaults
|
||||
for network access (npm, GitHub, LLM providers) and filesystem protections.
|
||||
Use --no-extend for a minimal config, or --extend to choose a different template.
|
||||
|
||||
Examples:
|
||||
# Preview import (prints JSON to stdout)
|
||||
fence import --claude
|
||||
|
||||
# Save to the default config path
|
||||
# Linux: ~/.config/fence/fence.json
|
||||
# macOS: ~/Library/Application Support/fence/fence.json
|
||||
fence import --claude --save
|
||||
|
||||
# Save to a specific output file
|
||||
fence import --claude -o ./fence.json
|
||||
|
||||
# Import from a specific Claude Code settings file
|
||||
fence import --claude -f ~/.claude/settings.json --save
|
||||
|
||||
# Import without extending any template (minimal config)
|
||||
fence import --claude --no-extend --save
|
||||
|
||||
# Import and extend a different template
|
||||
fence import --claude --extend local-dev-server --save`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !claudeMode {
|
||||
return fmt.Errorf("no import source specified. Use --claude to import from Claude Code")
|
||||
}
|
||||
|
||||
opts := importer.DefaultImportOptions()
|
||||
if noExtend {
|
||||
opts.Extends = ""
|
||||
} else if extendTmpl != "" {
|
||||
opts.Extends = extendTmpl
|
||||
}
|
||||
|
||||
result, err := importer.ImportFromClaude(inputFile, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to import Claude settings: %w", err)
|
||||
}
|
||||
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
|
||||
}
|
||||
if len(result.Warnings) > 0 {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
|
||||
// Determine output destination
|
||||
var destPath string
|
||||
if saveFlag {
|
||||
destPath = config.DefaultConfigPath()
|
||||
} else if outputFile != "" {
|
||||
destPath = outputFile
|
||||
}
|
||||
|
||||
if destPath != "" {
|
||||
if !forceFlag {
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
fmt.Printf("File %q already exists. Overwrite? [y/N] ", destPath)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := importer.WriteConfig(result.Config, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
|
||||
fmt.Printf("Written to %q\n", destPath)
|
||||
} else {
|
||||
// Print clean JSON to stdout, helpful info to stderr (don't interfere with piping)
|
||||
data, err := importer.MarshalConfigJSON(result.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
if result.Config.Extends != "" {
|
||||
fmt.Fprintf(os.Stderr, "\n# Extends %q - inherited rules not shown\n", result.Config.Extends)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "# Imported %d rules from %s\n", result.RulesImported, result.SourcePath)
|
||||
fmt.Fprintf(os.Stderr, "# Use --save to write to the default config path\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings")
|
||||
cmd.Flags().StringVarP(&inputFile, "file", "f", "", "Path to settings file (default: ~/.claude/settings.json for --claude)")
|
||||
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path")
|
||||
cmd.Flags().BoolVar(&saveFlag, "save", false, "Save to the default config path")
|
||||
cmd.Flags().BoolVarP(&forceFlag, "force", "y", false, "Overwrite existing file without prompting")
|
||||
cmd.Flags().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)")
|
||||
cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)")
|
||||
cmd.MarkFlagsMutuallyExclusive("extend", "no-extend")
|
||||
cmd.MarkFlagsMutuallyExclusive("save", "output")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newCompletionCmd creates the completion subcommand for shell completions.
|
||||
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Long: `Generate shell completion scripts for fence.
|
||||
|
||||
Examples:
|
||||
# Bash (load in current session)
|
||||
source <(fence completion bash)
|
||||
|
||||
# Zsh (load in current session)
|
||||
source <(fence completion zsh)
|
||||
|
||||
# Fish (load in current session)
|
||||
fence completion fish | source
|
||||
|
||||
# PowerShell (load in current session)
|
||||
fence completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
To persist completions, redirect output to the appropriate completions
|
||||
directory for your shell (e.g., /etc/bash_completion.d/ for bash,
|
||||
${fpath[1]}/_fence for zsh, ~/.config/fish/completions/fence.fish for fish).
|
||||
`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
return rootCmd.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh":
|
||||
return rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
return rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printTemplates prints all available templates to stdout.
|
||||
func printTemplates() {
|
||||
fmt.Println("Available templates:")
|
||||
fmt.Println()
|
||||
for _, t := range templates.List() {
|
||||
fmt.Printf(" %-20s %s\n", t.Name, t.Description)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("Usage: fence -t <template> <command>")
|
||||
fmt.Println("Example: fence -t code -- code")
|
||||
}
|
||||
|
||||
// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
|
||||
// It applies Landlock restrictions and then execs the user command.
|
||||
// Usage: fence --landlock-apply [--debug] -- <command...>
|
||||
// Config is passed via FENCE_CONFIG_JSON environment variable.
|
||||
func runLandlockWrapper() {
|
||||
// Parse arguments: --landlock-apply [--debug] -- <command...>
|
||||
args := os.Args[2:] // Skip "fence" and "--landlock-apply"
|
||||
|
||||
var debugMode bool
|
||||
var cmdStart int
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--debug":
|
||||
debugMode = true
|
||||
case "--":
|
||||
cmdStart = i + 1
|
||||
goto parseCommand
|
||||
default:
|
||||
// Assume rest is the command
|
||||
cmdStart = i
|
||||
goto parseCommand
|
||||
}
|
||||
}
|
||||
|
||||
parseCommand:
|
||||
if cmdStart >= len(args) {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Error: no command specified\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
command := args[cmdStart:]
|
||||
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Applying Landlock restrictions\n")
|
||||
}
|
||||
|
||||
// Only apply Landlock on Linux
|
||||
if platform.Detect() == platform.Linux {
|
||||
// Load config from environment variable (passed by parent fence process)
|
||||
var cfg *config.Config
|
||||
if configJSON := os.Getenv("FENCE_CONFIG_JSON"); configJSON != "" {
|
||||
cfg = &config.Config{}
|
||||
if err := json.Unmarshal([]byte(configJSON), cfg); err != nil {
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Warning: failed to parse config: %v\n", err)
|
||||
}
|
||||
cfg = nil
|
||||
}
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = config.Default()
|
||||
}
|
||||
|
||||
// Get current working directory for relative path resolution
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Apply Landlock restrictions
|
||||
err := sandbox.ApplyLandlockFromConfig(cfg, cwd, nil, debugMode)
|
||||
if err != nil {
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Warning: Landlock not applied: %v\n", err)
|
||||
}
|
||||
// Continue without Landlock - bwrap still provides isolation
|
||||
} else if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Landlock restrictions applied\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Find the executable
|
||||
execPath, err := exec.LookPath(command[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Error: command not found: %s\n", command[0])
|
||||
os.Exit(127)
|
||||
}
|
||||
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec: %s %v\n", execPath, command[1:])
|
||||
}
|
||||
|
||||
// Sanitize environment (strips LD_PRELOAD, etc.)
|
||||
hardenedEnv := sandbox.FilterDangerousEnv(os.Environ())
|
||||
|
||||
// Exec the command (replaces this process)
|
||||
err = syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
412
cmd/greywall/main.go
Normal file
412
cmd/greywall/main.go
Normal file
@@ -0,0 +1,412 @@
|
||||
// Package main implements the greywall CLI.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Build-time variables (set via -ldflags)
|
||||
var (
|
||||
version = "dev"
|
||||
buildTime = "unknown"
|
||||
gitCommit = "unknown"
|
||||
)
|
||||
|
||||
var (
|
||||
debug bool
|
||||
monitor bool
|
||||
settingsPath string
|
||||
proxyURL string
|
||||
dnsAddr string
|
||||
cmdString string
|
||||
exposePorts []string
|
||||
exitCode int
|
||||
showVersion bool
|
||||
linuxFeatures bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for internal --landlock-apply mode (used inside sandbox)
|
||||
// This must be checked before cobra to avoid flag conflicts
|
||||
if len(os.Args) >= 2 && os.Args[1] == "--landlock-apply" {
|
||||
runLandlockWrapper()
|
||||
return
|
||||
}
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "greywall [flags] -- [command...]",
|
||||
Short: "Run commands in a sandbox with network and filesystem restrictions",
|
||||
Long: `greywall is a command-line tool that runs commands in a sandboxed environment
|
||||
with network and filesystem restrictions.
|
||||
|
||||
By default, all network access is blocked. Use --proxy to route traffic through
|
||||
an external SOCKS5 proxy, or configure a proxy URL in your settings file at
|
||||
~/.config/greywall/greywall.json (or ~/Library/Application Support/greywall/greywall.json on macOS).
|
||||
|
||||
On Linux, greywall uses tun2socks for truly transparent proxying: all TCP/UDP traffic
|
||||
from any binary is captured at the kernel level via a TUN device and forwarded
|
||||
through the external SOCKS5 proxy. No application awareness needed.
|
||||
|
||||
On macOS, greywall uses environment variables (best-effort) to direct traffic
|
||||
to the proxy.
|
||||
|
||||
Examples:
|
||||
greywall -- curl https://example.com # Blocked (no proxy)
|
||||
greywall --proxy socks5://localhost:1080 -- curl https://example.com # Via proxy
|
||||
greywall -- curl -s https://example.com # Use -- to separate flags
|
||||
greywall -c "echo hello && ls" # Run with shell expansion
|
||||
greywall --settings config.json npm install
|
||||
greywall -p 3000 -c "npm run dev" # Expose port 3000
|
||||
|
||||
Configuration file format:
|
||||
{
|
||||
"network": {
|
||||
"proxyUrl": "socks5://localhost:1080"
|
||||
},
|
||||
"filesystem": {
|
||||
"denyRead": [],
|
||||
"allowWrite": ["."],
|
||||
"denyWrite": []
|
||||
},
|
||||
"command": {
|
||||
"deny": ["git push", "npm publish"]
|
||||
}
|
||||
}`,
|
||||
RunE: runCommand,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
}
|
||||
|
||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
|
||||
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
|
||||
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (e.g., socks5://localhost:1080)")
|
||||
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (e.g., localhost:3153)")
|
||||
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
|
||||
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
|
||||
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
|
||||
rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit")
|
||||
|
||||
rootCmd.Flags().SetInterspersed(true)
|
||||
|
||||
rootCmd.AddCommand(newCompletionCmd(rootCmd))
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
exitCode = 1
|
||||
}
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func runCommand(cmd *cobra.Command, args []string) error {
|
||||
if showVersion {
|
||||
fmt.Printf("greywall - lightweight, container-free sandbox for running untrusted commands\n")
|
||||
fmt.Printf(" Version: %s\n", version)
|
||||
fmt.Printf(" Built: %s\n", buildTime)
|
||||
fmt.Printf(" Commit: %s\n", gitCommit)
|
||||
return nil
|
||||
}
|
||||
|
||||
if linuxFeatures {
|
||||
sandbox.PrintLinuxFeatures()
|
||||
return nil
|
||||
}
|
||||
|
||||
var command string
|
||||
switch {
|
||||
case cmdString != "":
|
||||
command = cmdString
|
||||
case len(args) > 0:
|
||||
command = sandbox.ShellQuote(args)
|
||||
default:
|
||||
return fmt.Errorf("no command specified. Use -c <command> or provide command arguments")
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Command: %s\n", command)
|
||||
}
|
||||
|
||||
var ports []int
|
||||
for _, p := range exposePorts {
|
||||
port, err := strconv.Atoi(p)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port: %s", p)
|
||||
}
|
||||
ports = append(ports, port)
|
||||
}
|
||||
|
||||
if debug && len(ports) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Exposing ports: %v\n", ports)
|
||||
}
|
||||
|
||||
// Load config: settings file > default path > default config
|
||||
var cfg *config.Config
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case settingsPath != "":
|
||||
cfg, err = config.Load(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
default:
|
||||
configPath := config.DefaultConfigPath()
|
||||
cfg, err = config.Load(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] No config found at %s, using default (block all network)\n", configPath)
|
||||
}
|
||||
cfg = config.Default()
|
||||
}
|
||||
}
|
||||
|
||||
// CLI flags override config
|
||||
if proxyURL != "" {
|
||||
cfg.Network.ProxyURL = proxyURL
|
||||
}
|
||||
if dnsAddr != "" {
|
||||
cfg.Network.DnsAddr = dnsAddr
|
||||
}
|
||||
|
||||
manager := sandbox.NewManager(cfg, debug, monitor)
|
||||
manager.SetExposedPorts(ports)
|
||||
defer manager.Cleanup()
|
||||
|
||||
if err := manager.Initialize(); err != nil {
|
||||
return fmt.Errorf("failed to initialize sandbox: %w", err)
|
||||
}
|
||||
|
||||
var logMonitor *sandbox.LogMonitor
|
||||
if monitor {
|
||||
logMonitor = sandbox.NewLogMonitor(sandbox.GetSessionSuffix())
|
||||
if logMonitor != nil {
|
||||
if err := logMonitor.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to start log monitor: %v\n", err)
|
||||
} else {
|
||||
defer logMonitor.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sandboxedCommand, err := manager.WrapCommand(command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wrap command: %w", err)
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Sandboxed command: %s\n", sandboxedCommand)
|
||||
}
|
||||
|
||||
hardenedEnv := sandbox.GetHardenedEnv()
|
||||
if debug {
|
||||
if stripped := sandbox.GetStrippedEnvVars(os.Environ()); len(stripped) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[greywall] Stripped dangerous env vars: %v\n", stripped)
|
||||
}
|
||||
}
|
||||
|
||||
execCmd := exec.Command("sh", "-c", sandboxedCommand) //nolint:gosec // sandboxedCommand is constructed from user input - intentional
|
||||
execCmd.Env = hardenedEnv
|
||||
execCmd.Stdin = os.Stdin
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Start the command (non-blocking) so we can get the PID
|
||||
if err := execCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start command: %w", err)
|
||||
}
|
||||
|
||||
// Start Linux monitors (eBPF tracing for filesystem violations)
|
||||
var linuxMonitors *sandbox.LinuxMonitors
|
||||
if monitor && execCmd.Process != nil {
|
||||
linuxMonitors, _ = sandbox.StartLinuxMonitor(execCmd.Process.Pid, sandbox.LinuxSandboxOptions{
|
||||
Monitor: true,
|
||||
Debug: debug,
|
||||
UseEBPF: true,
|
||||
})
|
||||
if linuxMonitors != nil {
|
||||
defer linuxMonitors.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
sigCount := 0
|
||||
for sig := range sigChan {
|
||||
sigCount++
|
||||
if execCmd.Process == nil {
|
||||
continue
|
||||
}
|
||||
// First signal: graceful termination; second signal: force kill
|
||||
if sigCount >= 2 {
|
||||
_ = execCmd.Process.Kill()
|
||||
} else {
|
||||
_ = execCmd.Process.Signal(sig)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for command to finish
|
||||
if err := execCmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
// Set exit code but don't os.Exit() here - let deferred cleanup run
|
||||
exitCode = exitErr.ExitCode()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newCompletionCmd creates the completion subcommand for shell completions.
|
||||
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Long: `Generate shell completion scripts for greywall.
|
||||
|
||||
Examples:
|
||||
# Bash (load in current session)
|
||||
source <(greywall completion bash)
|
||||
|
||||
# Zsh (load in current session)
|
||||
source <(greywall completion zsh)
|
||||
|
||||
# Fish (load in current session)
|
||||
greywall completion fish | source
|
||||
|
||||
# PowerShell (load in current session)
|
||||
greywall completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
To persist completions, redirect output to the appropriate completions
|
||||
directory for your shell (e.g., /etc/bash_completion.d/ for bash,
|
||||
${fpath[1]}/_greywall for zsh, ~/.config/fish/completions/greywall.fish for fish).
|
||||
`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
return rootCmd.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh":
|
||||
return rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
return rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
|
||||
// It applies Landlock restrictions and then execs the user command.
|
||||
// Usage: greywall --landlock-apply [--debug] -- <command...>
|
||||
// Config is passed via GREYWALL_CONFIG_JSON environment variable.
|
||||
func runLandlockWrapper() {
|
||||
// Parse arguments: --landlock-apply [--debug] -- <command...>
|
||||
args := os.Args[2:] // Skip "greywall" and "--landlock-apply"
|
||||
|
||||
var debugMode bool
|
||||
var cmdStart int
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--debug":
|
||||
debugMode = true
|
||||
case "--":
|
||||
cmdStart = i + 1
|
||||
goto parseCommand
|
||||
default:
|
||||
// Assume rest is the command
|
||||
cmdStart = i
|
||||
goto parseCommand
|
||||
}
|
||||
}
|
||||
|
||||
parseCommand:
|
||||
if cmdStart >= len(args) {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Error: no command specified\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
command := args[cmdStart:]
|
||||
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Applying Landlock restrictions\n")
|
||||
}
|
||||
|
||||
// Only apply Landlock on Linux
|
||||
if platform.Detect() == platform.Linux {
|
||||
// Load config from environment variable (passed by parent greywall process)
|
||||
var cfg *config.Config
|
||||
if configJSON := os.Getenv("GREYWALL_CONFIG_JSON"); configJSON != "" {
|
||||
cfg = &config.Config{}
|
||||
if err := json.Unmarshal([]byte(configJSON), cfg); err != nil {
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Warning: failed to parse config: %v\n", err)
|
||||
}
|
||||
cfg = nil
|
||||
}
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = config.Default()
|
||||
}
|
||||
|
||||
// Get current working directory for relative path resolution
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Apply Landlock restrictions
|
||||
err := sandbox.ApplyLandlockFromConfig(cfg, cwd, nil, debugMode)
|
||||
if err != nil {
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Warning: Landlock not applied: %v\n", err)
|
||||
}
|
||||
// Continue without Landlock - bwrap still provides isolation
|
||||
} else if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Landlock restrictions applied\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Find the executable
|
||||
execPath, err := exec.LookPath(command[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Error: command not found: %s\n", command[0])
|
||||
os.Exit(127)
|
||||
}
|
||||
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Exec: %s %v\n", execPath, command[1:])
|
||||
}
|
||||
|
||||
// Sanitize environment (strips LD_PRELOAD, etc.)
|
||||
hardenedEnv := sandbox.FilterDangerousEnv(os.Environ())
|
||||
|
||||
// Exec the command (replaces this process)
|
||||
err = syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock-wrapper] Exec failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
# Fence Documentation
|
||||
# Greywall 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.
|
||||
Greywall 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)
|
||||
- [Quickstart](quickstart.md) - Install greywall and run your first sandboxed command in 5 minutes
|
||||
- [Why Greywall](why-greywall.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
|
||||
- [Using Greywall with AI agents](agents.md) - Defense-in-depth and policy standardization
|
||||
- [Recipes](recipes/README.md) - Common workflows (npm/pip/git/CI)
|
||||
- [Templates](./templates.md) - Copy/paste templates you can start from
|
||||
|
||||
## Reference
|
||||
|
||||
- [README](../README.md) - CLI usage
|
||||
- [Library Usage (Go)](library.md) - Using Fence as a Go package
|
||||
- [Configuration](./configuration.md) - How to configure Fence
|
||||
- [Architecture](../ARCHITECTURE.md) - How fence works under the hood
|
||||
- [Library Usage (Go)](library.md) - Using Greywall as a Go package
|
||||
- [Configuration](./configuration.md) - How to configure Greywall
|
||||
- [Architecture](../ARCHITECTURE.md) - How greywall works under the hood
|
||||
- [Security model](security-model.md) - Threat model, guarantees, and limitations
|
||||
- [Linux security features](linux-security-features.md) - Landlock, seccomp, eBPF details and fallback behavior
|
||||
- [Testing](testing.md) - How to run tests and write new ones
|
||||
@@ -36,20 +36,20 @@ See [`examples/`](../examples/README.md) for runnable demos.
|
||||
|
||||
```bash
|
||||
# Block all network (default)
|
||||
fence <command>
|
||||
greywall <command>
|
||||
|
||||
# Use custom config
|
||||
fence --settings ./fence.json <command>
|
||||
greywall --settings ./greywall.json <command>
|
||||
|
||||
# Debug mode (verbose output)
|
||||
fence -d <command>
|
||||
greywall -d <command>
|
||||
|
||||
# Monitor mode (show blocked requests)
|
||||
fence -m <command>
|
||||
greywall -m <command>
|
||||
|
||||
# Expose port for servers
|
||||
fence -p 3000 <command>
|
||||
greywall -p 3000 <command>
|
||||
|
||||
# Run shell command
|
||||
fence -c "echo hello && ls"
|
||||
greywall -c "echo hello && ls"
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Using Fence with AI Agents
|
||||
# Using Greywall 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:
|
||||
Many popular coding agents already include sandboxing. Greywall can still be useful when you want a tool-agnostic policy layer that works the same way across:
|
||||
|
||||
- local developer machines
|
||||
- CI jobs
|
||||
@@ -15,7 +15,7 @@ Treat an agent as "semi-trusted automation":
|
||||
- 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.
|
||||
Greywall can also reduce the risk of running agents with fewer interactive permission prompts (e.g. "skip permissions"), as long as your Greywall 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
|
||||
|
||||
@@ -33,7 +33,7 @@ Fence can also reduce the risk of running agents with fewer interactive permissi
|
||||
Run:
|
||||
|
||||
```bash
|
||||
fence --settings ./fence.json <agent-command>
|
||||
greywall --settings ./greywall.json <agent-command>
|
||||
```
|
||||
|
||||
## Popular CLI coding agents
|
||||
@@ -43,7 +43,7 @@ We provide these template for guardrailing CLI coding agents:
|
||||
- [`code`](/internal/templates/code.json) - Strict deny-by-default network filtering via proxy. Works with agents that respect `HTTP_PROXY`. Blocks cloud metadata APIs, protects secrets, restricts dangerous commands.
|
||||
- [`code-relaxed`](/internal/templates/code-relaxed.json) - Allows direct network connections for agents that ignore `HTTP_PROXY`. Same filesystem/command protections as `code`, but `deniedDomains` only enforced for proxy-respecting apps.
|
||||
|
||||
You can use it like `fence -t code -- claude`.
|
||||
You can use it like `greywall -t code -- claude`.
|
||||
|
||||
| Agent | Works with template | Notes |
|
||||
|-------|--------| ----- |
|
||||
@@ -60,7 +60,7 @@ Note: On Linux, if OpenCode or Gemini CLI is installed via Linuxbrew, Landlock c
|
||||
|
||||
## Protecting your environment
|
||||
|
||||
Fence includes additional "dangerous file protection" (writes blocked regardless of config) to reduce persistence and environment-tampering vectors like:
|
||||
Greywall 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.)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Benchmarking
|
||||
|
||||
This document describes how to run, interpret, and compare sandbox performance benchmarks for Fence.
|
||||
This document describes how to run, interpret, and compare sandbox performance benchmarks for Greywall.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -29,9 +29,9 @@ go test -run=^$ -bench=. -benchmem ./internal/sandbox/...
|
||||
|
||||
### Layer 1: CLI Benchmarks (`scripts/benchmark.sh`)
|
||||
|
||||
**What it measures**: Real-world agent cost - full `fence` invocation including proxy startup, socat bridges (Linux), and sandbox-exec/bwrap setup.
|
||||
**What it measures**: Real-world agent cost - full `greywall` invocation including proxy startup, socat bridges (Linux), and sandbox-exec/bwrap setup.
|
||||
|
||||
This is the most realistic benchmark for understanding the cost of running agent commands through Fence.
|
||||
This is the most realistic benchmark for understanding the cost of running agent commands through Greywall.
|
||||
|
||||
```bash
|
||||
# Full benchmark suite
|
||||
@@ -51,7 +51,7 @@ This is the most realistic benchmark for understanding the cost of running agent
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-b, --binary PATH` | Path to fence binary (default: ./fence) |
|
||||
| `-b, --binary PATH` | Path to greywall binary (default: ./greywall) |
|
||||
| `-o, --output DIR` | Output directory (default: ./benchmarks) |
|
||||
| `-n, --runs N` | Minimum runs per benchmark (default: 30) |
|
||||
| `-q, --quick` | Quick mode: fewer runs, skip slow benchmarks |
|
||||
@@ -92,13 +92,13 @@ benchstat bench.txt
|
||||
|
||||
```bash
|
||||
# Quick syscall cost breakdown
|
||||
strace -f -c ./fence -- true
|
||||
strace -f -c ./greywall -- true
|
||||
|
||||
# Context switches, page faults
|
||||
perf stat -- ./fence -- true
|
||||
perf stat -- ./greywall -- true
|
||||
|
||||
# Full profiling (flamegraph-ready)
|
||||
perf record -F 99 -g -- ./fence -- git status
|
||||
perf record -F 99 -g -- ./greywall -- git status
|
||||
perf report
|
||||
```
|
||||
|
||||
@@ -106,10 +106,10 @@ perf report
|
||||
|
||||
```bash
|
||||
# Time Profiler via Instruments
|
||||
xcrun xctrace record --template 'Time Profiler' --launch -- ./fence -- true
|
||||
xcrun xctrace record --template 'Time Profiler' --launch -- ./greywall -- true
|
||||
|
||||
# Quick call-stack snapshot
|
||||
./fence -- sleep 5 &
|
||||
./greywall -- sleep 5 &
|
||||
sample $! 5 -file sample.txt
|
||||
```
|
||||
|
||||
@@ -150,7 +150,7 @@ The overhead factor decreases as the actual workload increases (because sandbox
|
||||
|
||||
1. Run benchmarks on each platform independently
|
||||
2. Compare overhead factors, not absolute times
|
||||
3. Use the same fence version and workloads
|
||||
3. Use the same greywall version and workloads
|
||||
|
||||
```bash
|
||||
# On macOS
|
||||
@@ -256,7 +256,7 @@ Linux initialization is ~3,700x slower because it must:
|
||||
|
||||
macOS only generates a Seatbelt profile string (very cheap).
|
||||
|
||||
### Cold Start Overhead (one `fence` invocation per command)
|
||||
### Cold Start Overhead (one `greywall` invocation per command)
|
||||
|
||||
| Workload | Linux | macOS |
|
||||
|----------|-------|-------|
|
||||
@@ -264,7 +264,7 @@ macOS only generates a Seatbelt profile string (very cheap).
|
||||
| Python | 124 ms | 33 ms |
|
||||
| Git status | 114 ms | 25 ms |
|
||||
|
||||
This is the realistic cost for scripts running `fence -c "command"` repeatedly.
|
||||
This is the realistic cost for scripts running `greywall -c "command"` repeatedly.
|
||||
|
||||
### Warm Path Overhead (pre-initialized manager)
|
||||
|
||||
@@ -289,9 +289,9 @@ Overhead decreases as the actual workload increases (sandbox setup is fixed cost
|
||||
|
||||
## Impact on Agent Usage
|
||||
|
||||
### Long-Running Agents (`fence claude`, `fence codex`)
|
||||
### Long-Running Agents (`greywall claude`, `greywall codex`)
|
||||
|
||||
For agents that run as a child process under fence:
|
||||
For agents that run as a child process under greywall:
|
||||
|
||||
| Phase | Cost |
|
||||
|-------|------|
|
||||
@@ -306,11 +306,11 @@ Child processes inherit the sandbox - no re-initialization, no WrapCommand overh
|
||||
| `git status` | 2.1 ms | 5.9 ms |
|
||||
| Python script | 11 ms | 15 ms |
|
||||
|
||||
**Bottom line**: For `fence <agent>` usage, sandbox overhead is a one-time startup cost. Tool calls inside the agent run at native speed.
|
||||
**Bottom line**: For `greywall <agent>` usage, sandbox overhead is a one-time startup cost. Tool calls inside the agent run at native speed.
|
||||
|
||||
### Per-Command Invocation (`fence -c "command"`)
|
||||
### Per-Command Invocation (`greywall -c "command"`)
|
||||
|
||||
For scripts or CI running fence per command:
|
||||
For scripts or CI running greywall per command:
|
||||
|
||||
| Session | Linux Cost | macOS Cost |
|
||||
|---------|------------|------------|
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Concepts
|
||||
|
||||
Fence combines two ideas:
|
||||
Greywall 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.
|
||||
By default, greywall blocks all outbound network access.
|
||||
|
||||
When you allow domains, fence:
|
||||
When you allow domains, greywall:
|
||||
|
||||
- Starts local HTTP and SOCKS5 proxies
|
||||
- Sets proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`)
|
||||
@@ -29,13 +29,13 @@ These are separate on purpose. A typical safe default for dev servers is:
|
||||
|
||||
## Filesystem model
|
||||
|
||||
Fence is designed around "read mostly, write narrowly":
|
||||
Greywall 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.
|
||||
Greywall 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration
|
||||
|
||||
Fence reads settings from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS). Legacy `~/.fence.json` is also supported. Pass `--settings ./fence.json` to use a custom path. Config files support JSONC.
|
||||
Greywall reads settings from `~/.config/greywall/greywall.json` by default (or `~/Library/Application Support/greywall/greywall.json` on macOS). Legacy `~/.greywall.json` is also supported. Pass `--settings ./greywall.json` to use a custom path. Config files support JSONC.
|
||||
|
||||
Example config:
|
||||
|
||||
@@ -60,7 +60,7 @@ You can also extend other config files using absolute or relative paths:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "/etc/fence/company-base.json",
|
||||
"extends": "/etc/greywall/company-base.json",
|
||||
"filesystem": {
|
||||
"denyRead": ["~/company-secrets/**"]
|
||||
}
|
||||
@@ -143,7 +143,7 @@ Example:
|
||||
|
||||
### Default Denied Commands
|
||||
|
||||
When `useDefaults` is `true` (the default), fence blocks these dangerous commands:
|
||||
When `useDefaults` is `true` (the default), greywall blocks these dangerous commands:
|
||||
|
||||
- System control: `shutdown`, `reboot`, `halt`, `poweroff`, `init 0/6`
|
||||
- Kernel manipulation: `insmod`, `rmmod`, `modprobe`, `kexec`
|
||||
@@ -155,7 +155,7 @@ To disable defaults: `"useDefaults": false`
|
||||
|
||||
### Command Detection
|
||||
|
||||
Fence detects blocked commands in:
|
||||
Greywall detects blocked commands in:
|
||||
|
||||
- Direct commands: `git push origin main`
|
||||
- Command chains: `ls && git push` or `ls; git push`
|
||||
@@ -260,26 +260,26 @@ SSH host patterns support wildcards anywhere:
|
||||
|
||||
## Importing from Claude Code
|
||||
|
||||
If you've been using Claude Code and have already built up permission rules, you can import them into fence:
|
||||
If you've been using Claude Code and have already built up permission rules, you can import them into greywall:
|
||||
|
||||
```bash
|
||||
# Preview import (prints JSON to stdout)
|
||||
fence import --claude
|
||||
greywall import --claude
|
||||
|
||||
# Save to the default config path
|
||||
fence import --claude --save
|
||||
greywall import --claude --save
|
||||
|
||||
# Import from a specific file
|
||||
fence import --claude -f ~/.claude/settings.json --save
|
||||
greywall import --claude -f ~/.claude/settings.json --save
|
||||
|
||||
# Save to a specific output file
|
||||
fence import --claude -o ./fence.json
|
||||
greywall import --claude -o ./greywall.json
|
||||
|
||||
# Import without extending any template (minimal config)
|
||||
fence import --claude --no-extend --save
|
||||
greywall import --claude --no-extend --save
|
||||
|
||||
# Import and extend a different template
|
||||
fence import --claude --extend local-dev-server --save
|
||||
greywall import --claude --extend local-dev-server --save
|
||||
```
|
||||
|
||||
### Default Template
|
||||
@@ -294,7 +294,7 @@ Use `--no-extend` if you want a minimal config without these defaults, or `--ext
|
||||
|
||||
### Permission Mapping
|
||||
|
||||
| Claude Code | Fence |
|
||||
| Claude Code | Greywall |
|
||||
|-------------|-------|
|
||||
| `Bash(xyz)` allow | `command.allow: ["xyz"]` |
|
||||
| `Bash(xyz:*)` deny | `command.deny: ["xyz"]` |
|
||||
@@ -302,9 +302,9 @@ Use `--no-extend` if you want a minimal config without these defaults, or `--ext
|
||||
| `Write(path)` allow | `filesystem.allowWrite: [path]` |
|
||||
| `Write(path)` deny | `filesystem.denyWrite: [path]` |
|
||||
| `Edit(path)` | Same as `Write(path)` |
|
||||
| `ask` rules | Converted to deny (fence doesn't support interactive prompts) |
|
||||
| `ask` rules | Converted to deny (greywall doesn't support interactive prompts) |
|
||||
|
||||
Global tool permissions (e.g., bare `Read`, `Write`, `Grep`) are skipped since fence uses path/command-based rules.
|
||||
Global tool permissions (e.g., bare `Read`, `Write`, `Grep`) are skipped since greywall uses path/command-based rules.
|
||||
|
||||
## See Also
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Library Usage (Go)
|
||||
|
||||
Fence can be used as a Go library to sandbox commands programmatically.
|
||||
Greywall can be used as a Go library to sandbox commands programmatically.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/Use-Tusk/fence
|
||||
go get gitea.app.monadical.io/monadical/greywall
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -17,25 +17,25 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Use-Tusk/fence/pkg/fence"
|
||||
"gitea.app.monadical.io/monadical/greywall/pkg/greywall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check platform support
|
||||
if !fence.IsSupported() {
|
||||
if !greywall.IsSupported() {
|
||||
fmt.Println("Sandboxing not supported on this platform")
|
||||
return
|
||||
}
|
||||
|
||||
// Create config
|
||||
cfg := &fence.Config{
|
||||
Network: fence.NetworkConfig{
|
||||
cfg := &greywall.Config{
|
||||
Network: greywall.NetworkConfig{
|
||||
AllowedDomains: []string{"api.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
// Create and initialize manager
|
||||
manager := fence.NewManager(cfg, false, false)
|
||||
manager := greywall.NewManager(cfg, false, false)
|
||||
defer manager.Cleanup()
|
||||
|
||||
if err := manager.Initialize(); err != nil {
|
||||
@@ -64,7 +64,7 @@ func main() {
|
||||
Returns `true` if the current platform supports sandboxing (macOS or Linux).
|
||||
|
||||
```go
|
||||
if !fence.IsSupported() {
|
||||
if !greywall.IsSupported() {
|
||||
log.Fatal("Platform not supported")
|
||||
}
|
||||
```
|
||||
@@ -74,7 +74,7 @@ if !fence.IsSupported() {
|
||||
Returns a default configuration with all network blocked.
|
||||
|
||||
```go
|
||||
cfg := fence.DefaultConfig()
|
||||
cfg := greywall.DefaultConfig()
|
||||
cfg.Network.AllowedDomains = []string{"example.com"}
|
||||
```
|
||||
|
||||
@@ -83,18 +83,18 @@ cfg.Network.AllowedDomains = []string{"example.com"}
|
||||
Loads configuration from a JSON file. Supports JSONC (comments allowed).
|
||||
|
||||
```go
|
||||
cfg, err := fence.LoadConfig(fence.DefaultConfigPath())
|
||||
cfg, err := greywall.LoadConfig(greywall.DefaultConfigPath())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = fence.DefaultConfig() // File doesn't exist
|
||||
cfg = greywall.DefaultConfig() // File doesn't exist
|
||||
}
|
||||
```
|
||||
|
||||
#### `DefaultConfigPath() string`
|
||||
|
||||
Returns the default config file path (`~/.config/fence/fence.json` on Linux, `~/Library/Application Support/fence/fence.json` on macOS, with fallback to legacy `~/.fence.json`).
|
||||
Returns the default config file path (`~/.config/greywall/greywall.json` on Linux, `~/Library/Application Support/greywall/greywall.json` on macOS, with fallback to legacy `~/.greywall.json`).
|
||||
|
||||
#### `NewManager(cfg *Config, debug, monitor bool) *Manager`
|
||||
|
||||
@@ -113,7 +113,7 @@ Creates a new sandbox manager.
|
||||
Sets up sandbox infrastructure (starts HTTP and SOCKS proxies). Called automatically by `WrapCommand` if not already initialized.
|
||||
|
||||
```go
|
||||
manager := fence.NewManager(cfg, false, false)
|
||||
manager := greywall.NewManager(cfg, false, false)
|
||||
defer manager.Cleanup()
|
||||
|
||||
if err := manager.Initialize(); err != nil {
|
||||
@@ -222,8 +222,8 @@ type SSHConfig struct {
|
||||
### Allow specific domains
|
||||
|
||||
```go
|
||||
cfg := &fence.Config{
|
||||
Network: fence.NetworkConfig{
|
||||
cfg := &greywall.Config{
|
||||
Network: greywall.NetworkConfig{
|
||||
AllowedDomains: []string{
|
||||
"registry.npmjs.org",
|
||||
"*.github.com",
|
||||
@@ -236,8 +236,8 @@ cfg := &fence.Config{
|
||||
### Restrict filesystem access
|
||||
|
||||
```go
|
||||
cfg := &fence.Config{
|
||||
Filesystem: fence.FilesystemConfig{
|
||||
cfg := &greywall.Config{
|
||||
Filesystem: greywall.FilesystemConfig{
|
||||
AllowWrite: []string{".", "/tmp"},
|
||||
DenyRead: []string{"~/.ssh", "~/.aws"},
|
||||
},
|
||||
@@ -247,8 +247,8 @@ cfg := &fence.Config{
|
||||
### Block dangerous commands
|
||||
|
||||
```go
|
||||
cfg := &fence.Config{
|
||||
Command: fence.CommandConfig{
|
||||
cfg := &greywall.Config{
|
||||
Command: greywall.CommandConfig{
|
||||
Deny: []string{
|
||||
"rm -rf /",
|
||||
"git push",
|
||||
@@ -261,7 +261,7 @@ cfg := &fence.Config{
|
||||
### Expose dev server port
|
||||
|
||||
```go
|
||||
manager := fence.NewManager(cfg, false, false)
|
||||
manager := greywall.NewManager(cfg, false, false)
|
||||
manager.SetExposedPorts([]int{3000})
|
||||
defer manager.Cleanup()
|
||||
|
||||
@@ -271,12 +271,12 @@ wrapped, _ := manager.WrapCommand("npm run dev")
|
||||
### Load and extend config
|
||||
|
||||
```go
|
||||
cfg, err := fence.LoadConfig(fence.DefaultConfigPath())
|
||||
cfg, err := greywall.LoadConfig(greywall.DefaultConfigPath())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = fence.DefaultConfig()
|
||||
cfg = greywall.DefaultConfig()
|
||||
}
|
||||
|
||||
// Add additional restrictions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Linux Security Features
|
||||
|
||||
Fence uses multiple layers of security on Linux, with graceful fallback when features are unavailable.
|
||||
Greywall uses multiple layers of security on Linux, with graceful fallback when features are unavailable.
|
||||
|
||||
## Security Layers
|
||||
|
||||
@@ -13,13 +13,13 @@ Fence uses multiple layers of security on Linux, with graceful fallback when fea
|
||||
|
||||
## Feature Detection
|
||||
|
||||
Fence automatically detects available features and uses the best available combination.
|
||||
Greywall automatically detects available features and uses the best available combination.
|
||||
|
||||
To see what features are detected:
|
||||
|
||||
```bash
|
||||
# Check what features are available on your system
|
||||
fence --linux-features
|
||||
greywall --linux-features
|
||||
|
||||
# Example output:
|
||||
# Linux Sandbox Features:
|
||||
@@ -41,7 +41,7 @@ fence --linux-features
|
||||
|
||||
Landlock is applied via an **embedded wrapper** approach:
|
||||
|
||||
1. bwrap spawns `fence --landlock-apply -- <user-command>`
|
||||
1. bwrap spawns `greywall --landlock-apply -- <user-command>`
|
||||
2. The wrapper applies Landlock kernel restrictions
|
||||
3. The wrapper `exec()`s the user command
|
||||
|
||||
@@ -75,25 +75,25 @@ This provides **defense-in-depth**: both bwrap mounts AND Landlock kernel restri
|
||||
- **Impact**: `--unshare-net` is skipped; network is not fully isolated
|
||||
- **Cause**: Running in Docker, GitHub Actions, or other environments without `CAP_NET_ADMIN`
|
||||
- **Fallback**: Proxy-based filtering still works; filesystem/PID/seccomp isolation still active
|
||||
- **Check**: Run `fence --linux-features` and look for "Network namespace (--unshare-net): false"
|
||||
- **Check**: Run `greywall --linux-features` and look for "Network namespace (--unshare-net): false"
|
||||
- **Workaround**: Run with `sudo`, or in Docker use `--cap-add=NET_ADMIN`
|
||||
|
||||
> [!NOTE]
|
||||
> This is the most common "reduced isolation" scenario. Fence automatically detects this at startup and adapts. See the troubleshooting guide for more details.
|
||||
> This is the most common "reduced isolation" scenario. Greywall automatically detects this at startup and adapts. See the troubleshooting guide for more details.
|
||||
|
||||
### When bwrap is not available
|
||||
|
||||
- **Impact**: Cannot run fence on Linux
|
||||
- **Impact**: Cannot run greywall on Linux
|
||||
- **Solution**: Install bubblewrap: `apt install bubblewrap` or `dnf install bubblewrap`
|
||||
|
||||
### When socat is not available
|
||||
|
||||
- **Impact**: Cannot run fence on Linux
|
||||
- **Impact**: Cannot run greywall on Linux
|
||||
- **Solution**: Install socat: `apt install socat` or `dnf install socat`
|
||||
|
||||
## Blocked Syscalls (seccomp)
|
||||
|
||||
Fence blocks dangerous syscalls that could be used for sandbox escape or privilege escalation:
|
||||
Greywall blocks dangerous syscalls that could be used for sandbox escape or privilege escalation:
|
||||
|
||||
| Syscall | Reason |
|
||||
|---------|--------|
|
||||
@@ -111,13 +111,13 @@ Fence blocks dangerous syscalls that could be used for sandbox escape or privile
|
||||
|
||||
## Violation Monitoring
|
||||
|
||||
On Linux, violation monitoring (`fence -m`) shows:
|
||||
On Linux, violation monitoring (`greywall -m`) shows:
|
||||
|
||||
| Source | What it shows | Requirements |
|
||||
|--------|---------------|--------------|
|
||||
| `[fence:http]` | Blocked HTTP/HTTPS requests | None |
|
||||
| `[fence:socks]` | Blocked SOCKS connections | None |
|
||||
| `[fence:ebpf]` | Blocked filesystem access + syscalls | CAP_BPF or root |
|
||||
| `[greywall:http]` | Blocked HTTP/HTTPS requests | None |
|
||||
| `[greywall:socks]` | Blocked SOCKS connections | None |
|
||||
| `[greywall:ebpf]` | Blocked filesystem access + syscalls | CAP_BPF or root |
|
||||
|
||||
**Notes**:
|
||||
|
||||
@@ -127,7 +127,7 @@ On Linux, violation monitoring (`fence -m`) shows:
|
||||
|
||||
## Comparison with macOS
|
||||
|
||||
| Feature | macOS (Seatbelt) | Linux (fence) |
|
||||
| Feature | macOS (Seatbelt) | Linux (greywall) |
|
||||
|---------|------------------|---------------|
|
||||
| Filesystem control | Native | bwrap + Landlock |
|
||||
| Glob patterns | Native regex | Expanded at startup |
|
||||
@@ -181,12 +181,12 @@ sudo apk add bubblewrap socat
|
||||
For full violation visibility without root:
|
||||
|
||||
```bash
|
||||
# Grant CAP_BPF to the fence binary
|
||||
sudo setcap cap_bpf+ep /usr/local/bin/fence
|
||||
# Grant CAP_BPF to the greywall binary
|
||||
sudo setcap cap_bpf+ep /usr/local/bin/greywall
|
||||
```
|
||||
|
||||
Or run fence with sudo when monitoring is needed:
|
||||
Or run greywall with sudo when monitoring is needed:
|
||||
|
||||
```bash
|
||||
sudo fence -m <command>
|
||||
sudo greywall -m <command>
|
||||
```
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
### 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/
|
||||
git clone https://gitea.app.monadical.io/monadical/greywall
|
||||
cd greywall
|
||||
go build -o greywall ./cmd/greywall
|
||||
sudo mv greywall /usr/local/bin/
|
||||
```
|
||||
|
||||
### Using Go Install
|
||||
|
||||
```bash
|
||||
go install github.com/Use-Tusk/fence/cmd/fence@latest
|
||||
go install gitea.app.monadical.io/monadical/greywall/cmd/greywall@latest
|
||||
```
|
||||
|
||||
### Linux Dependencies
|
||||
@@ -32,30 +32,30 @@ sudo dnf install bubblewrap socat
|
||||
sudo pacman -S bubblewrap socat
|
||||
```
|
||||
|
||||
### Do I need sudo to run fence?
|
||||
### Do I need sudo to run greywall?
|
||||
|
||||
No, for most Linux systems. Fence works without root privileges because:
|
||||
No, for most Linux systems. Greywall works without root privileges because:
|
||||
|
||||
- Package-manager-installed `bubblewrap` is typically already setuid
|
||||
- Fence detects available capabilities and adapts automatically
|
||||
- Greywall detects available capabilities and adapts automatically
|
||||
|
||||
If some features aren't available (like network namespaces in Docker/CI), fence falls back gracefully - you'll still get filesystem isolation, command blocking, and proxy-based network filtering.
|
||||
If some features aren't available (like network namespaces in Docker/CI), greywall falls back gracefully - you'll still get filesystem isolation, command blocking, and proxy-based network filtering.
|
||||
|
||||
Run `fence --linux-features` to see what's available in your environment.
|
||||
Run `greywall --linux-features` to see what's available in your environment.
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
fence --version
|
||||
greywall --version
|
||||
```
|
||||
|
||||
## Your First Sandboxed Command
|
||||
|
||||
By default, fence blocks all network access:
|
||||
By default, greywall blocks all network access:
|
||||
|
||||
```bash
|
||||
# This will fail - network is blocked
|
||||
fence curl https://example.com
|
||||
greywall curl https://example.com
|
||||
```
|
||||
|
||||
You should see something like:
|
||||
@@ -66,7 +66,7 @@ curl: (56) CONNECT tunnel failed, response 403
|
||||
|
||||
## Allow Specific Domains
|
||||
|
||||
Create a config file at `~/.config/fence/fence.json` (or `~/Library/Application Support/fence/fence.json` on macOS):
|
||||
Create a config file at `~/.config/greywall/greywall.json` (or `~/Library/Application Support/greywall/greywall.json` on macOS):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -79,7 +79,7 @@ Create a config file at `~/.config/fence/fence.json` (or `~/Library/Application
|
||||
Now try again:
|
||||
|
||||
```bash
|
||||
fence curl https://example.com
|
||||
greywall curl https://example.com
|
||||
```
|
||||
|
||||
This time it succeeds!
|
||||
@@ -89,7 +89,7 @@ This time it succeeds!
|
||||
Use `-d` to see what's happening under the hood:
|
||||
|
||||
```bash
|
||||
fence -d curl https://example.com
|
||||
greywall -d curl https://example.com
|
||||
```
|
||||
|
||||
This shows:
|
||||
@@ -103,7 +103,7 @@ This shows:
|
||||
Use `-m` to see only violations and blocked requests:
|
||||
|
||||
```bash
|
||||
fence -m npm install
|
||||
greywall -m npm install
|
||||
```
|
||||
|
||||
This is useful for:
|
||||
@@ -117,7 +117,7 @@ This is useful for:
|
||||
Use `-c` to run compound commands:
|
||||
|
||||
```bash
|
||||
fence -c "echo hello && ls -la"
|
||||
greywall -c "echo hello && ls -la"
|
||||
```
|
||||
|
||||
## Expose Ports for Servers
|
||||
@@ -125,14 +125,14 @@ fence -c "echo hello && ls -la"
|
||||
If you're running a server that needs to accept connections:
|
||||
|
||||
```bash
|
||||
fence -p 3000 -c "npm run dev"
|
||||
greywall -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).
|
||||
- Read **[Why Greywall](why-greywall.md)** to understand when greywall 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)**.
|
||||
|
||||
@@ -18,7 +18,7 @@ Goal: make CI steps safer by default: minimal egress and controlled writes.
|
||||
Run:
|
||||
|
||||
```bash
|
||||
fence --settings ./fence.json -c "make test"
|
||||
greywall --settings ./greywall.json -c "make test"
|
||||
```
|
||||
|
||||
## Add only what you need
|
||||
@@ -26,7 +26,7 @@ fence --settings ./fence.json -c "make test"
|
||||
Use monitor mode to discover what a job tries to reach:
|
||||
|
||||
```bash
|
||||
fence -m --settings ./fence.json -c "make test"
|
||||
greywall -m --settings ./greywall.json -c "make test"
|
||||
```
|
||||
|
||||
Then allowlist only:
|
||||
|
||||
@@ -18,7 +18,7 @@ Goal: allow fetching code from a limited set of hosts.
|
||||
Run:
|
||||
|
||||
```bash
|
||||
fence --settings ./fence.json git clone https://github.com/OWNER/REPO.git
|
||||
greywall --settings ./greywall.json git clone https://github.com/OWNER/REPO.git
|
||||
```
|
||||
|
||||
## SSH clone
|
||||
@@ -28,5 +28,5 @@ SSH traffic may go through SOCKS5 (`ALL_PROXY`) depending on your git/ssh config
|
||||
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
|
||||
greywall -m --settings ./greywall.json git clone git@github.com:OWNER/REPO.git
|
||||
```
|
||||
|
||||
@@ -18,7 +18,7 @@ Goal: allow npm to fetch packages, but block unexpected egress.
|
||||
Run:
|
||||
|
||||
```bash
|
||||
fence --settings ./fence.json npm install
|
||||
greywall --settings ./greywall.json npm install
|
||||
```
|
||||
|
||||
## Iterate with monitor mode
|
||||
@@ -26,7 +26,7 @@ fence --settings ./fence.json npm install
|
||||
If installs fail, run:
|
||||
|
||||
```bash
|
||||
fence -m --settings ./fence.json npm install
|
||||
greywall -m --settings ./greywall.json npm install
|
||||
```
|
||||
|
||||
Then add the minimum extra domains required for your workflow (private registries, GitHub tarballs, etc.).
|
||||
|
||||
@@ -18,19 +18,19 @@ Goal: allow Python dependency fetching while keeping egress minimal.
|
||||
Run:
|
||||
|
||||
```bash
|
||||
fence --settings ./fence.json pip install -r requirements.txt
|
||||
greywall --settings ./greywall.json pip install -r requirements.txt
|
||||
```
|
||||
|
||||
For Poetry:
|
||||
|
||||
```bash
|
||||
fence --settings ./fence.json poetry install
|
||||
greywall --settings ./greywall.json poetry install
|
||||
```
|
||||
|
||||
## Iterate with monitor mode
|
||||
|
||||
```bash
|
||||
fence -m --settings ./fence.json poetry install
|
||||
greywall -m --settings ./greywall.json poetry install
|
||||
```
|
||||
|
||||
If you use private indexes, add those domains explicitly.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# 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).
|
||||
Greywall 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)
|
||||
## Threat model (what Greywall helps with)
|
||||
|
||||
Fence is useful when you want to reduce risk from:
|
||||
Greywall 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
|
||||
## What Greywall enforces
|
||||
|
||||
### Network
|
||||
|
||||
@@ -25,7 +25,7 @@ Important: domain filtering does not inspect content. If you allow a domain, cod
|
||||
|
||||
#### How allowlisting works
|
||||
|
||||
Fence combines OS-level enforcement with proxy-based allowlisting:
|
||||
Greywall 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`).
|
||||
@@ -41,11 +41,11 @@ Localhost is separate from "external domains":
|
||||
- **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.
|
||||
- Greywall includes an internal list of always-protected targets (e.g. shell configs, git hooks) to reduce common persistence vectors.
|
||||
|
||||
### Environment sanitization
|
||||
|
||||
Fence strips dangerous environment variables before passing them to sandboxed commands:
|
||||
Greywall strips dangerous environment variables before passing them to sandboxed commands:
|
||||
|
||||
- `LD_*` (Linux): `LD_PRELOAD`, `LD_LIBRARY_PATH`, etc.
|
||||
- `DYLD_*` (macOS): `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, etc.
|
||||
@@ -57,11 +57,11 @@ This prevents a library injection attack where a sandboxed process writes a mali
|
||||
- `-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)
|
||||
## Limitations (what Greywall 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.
|
||||
- **Content-based controls**: Greywall 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
|
||||
@@ -71,14 +71,14 @@ The proxy approach works well for many tools (curl, wget, git, npm, pip), but no
|
||||
- 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.
|
||||
Greywall'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.
|
||||
Greywall 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.
|
||||
Greywall 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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Config Templates
|
||||
|
||||
Fence includes built-in config templates for common use cases. Templates are embedded in the binary, so you can use them directly without copying files.
|
||||
Greywall includes built-in config templates for common use cases. Templates are embedded in the binary, so you can use them directly without copying files.
|
||||
|
||||
## Using templates
|
||||
|
||||
@@ -8,13 +8,13 @@ Use the `-t` / `--template` flag to apply a template:
|
||||
|
||||
```bash
|
||||
# Use a built-in template
|
||||
fence -t npm-install npm install
|
||||
greywall -t npm-install npm install
|
||||
|
||||
# Wraps Claude Code
|
||||
fence -t code -- claude
|
||||
greywall -t code -- claude
|
||||
|
||||
# List available templates
|
||||
fence --list-templates
|
||||
greywall --list-templates
|
||||
```
|
||||
|
||||
You can also copy and customize templates from [`internal/templates/`](/internal/templates/).
|
||||
|
||||
@@ -67,9 +67,9 @@ go test -v -count=1 ./internal/sandbox/...
|
||||
|
||||
#### Sandboxed Build Environments (Nix, etc.)
|
||||
|
||||
If you're packaging fence for a distribution (e.g., Nix, Homebrew, Debian), note that some integration tests will be skipped when running `go test` during the build.
|
||||
If you're packaging greywall for a distribution (e.g., Nix, Homebrew, Debian), note that some integration tests will be skipped when running `go test` during the build.
|
||||
|
||||
Fence's Landlock integration on Linux uses a wrapper approach: the `fence` binary re-executes itself with `--landlock-apply` inside the sandbox. Test binaries (e.g., `sandbox.test`) don't have this handler, so Landlock-specific tests automatically skip when not running as the `fence` CLI.
|
||||
Greywall's Landlock integration on Linux uses a wrapper approach: the `greywall` binary re-executes itself with `--landlock-apply` inside the sandbox. Test binaries (e.g., `sandbox.test`) don't have this handler, so Landlock-specific tests automatically skip when not running as the `greywall` CLI.
|
||||
|
||||
Tests that skip include those calling `skipIfLandlockNotUsable()`:
|
||||
|
||||
@@ -77,25 +77,25 @@ Tests that skip include those calling `skipIfLandlockNotUsable()`:
|
||||
- `TestLinux_LandlockProtectsGitHooks`
|
||||
- `TestLinux_LandlockProtectsGitConfig`
|
||||
- `TestLinux_LandlockProtectsBashrc`
|
||||
- `TestLinux_LandlockAllowsTmpFence`
|
||||
- `TestLinux_LandlockAllowsTmpGreywall`
|
||||
- `TestLinux_PathTraversalBlocked`
|
||||
- `TestLinux_SeccompBlocksDangerousSyscalls`
|
||||
|
||||
| Test Type | What it tests | Landlock coverage |
|
||||
|-----------|---------------|-------------------|
|
||||
| `go test` (integration) | Go APIs, bwrap isolation, command blocking | Skipped (test binary can't use `--landlock-apply`) |
|
||||
| `smoke_test.sh` | Actual `fence` CLI end-to-end | ✅ Full coverage |
|
||||
| `smoke_test.sh` | Actual `greywall` CLI end-to-end | ✅ Full coverage |
|
||||
|
||||
For full test coverage including Landlock, run the smoke tests against the built binary (see "Smoke Tests" section below).
|
||||
|
||||
**Nested sandboxing limitations:**
|
||||
|
||||
- **macOS**: Nested Seatbelt sandboxing is not supported. If the build environment already uses `sandbox-exec` (like Nix's Darwin sandbox), fence's tests cannot create another sandbox. The kernel returns `forbidden-sandbox-reinit`. This is a macOS limitation.
|
||||
- **macOS**: Nested Seatbelt sandboxing is not supported. If the build environment already uses `sandbox-exec` (like Nix's Darwin sandbox), greywall's tests cannot create another sandbox. The kernel returns `forbidden-sandbox-reinit`. This is a macOS limitation.
|
||||
- **Linux**: Tests should work in most build sandboxes, but Landlock tests will skip as explained above. Runtime functionality is unaffected.
|
||||
|
||||
### Smoke Tests
|
||||
|
||||
Smoke tests verify the compiled `fence` binary works end-to-end. Unlike integration tests (which test internal Go APIs), smoke tests exercise the CLI interface.
|
||||
Smoke tests verify the compiled `greywall` binary works end-to-end. Unlike integration tests (which test internal Go APIs), smoke tests exercise the CLI interface.
|
||||
|
||||
**File:** [`scripts/smoke_test.sh`](/scripts/smoke_test.sh)
|
||||
|
||||
@@ -105,7 +105,7 @@ Smoke tests verify the compiled `fence` binary works end-to-end. Unlike integrat
|
||||
- Filesystem restrictions via settings file
|
||||
- Command blocking via settings file
|
||||
- Network blocking
|
||||
- Environment variable injection (FENCE_SANDBOX, HTTP_PROXY)
|
||||
- Environment variable injection (GREYWALL_SANDBOX, HTTP_PROXY)
|
||||
- Tool compatibility (python3, node, git, rg) - ensure that frequently used tools don't break in sandbox
|
||||
|
||||
**Run:**
|
||||
@@ -115,10 +115,10 @@ Smoke tests verify the compiled `fence` binary works end-to-end. Unlike integrat
|
||||
./scripts/smoke_test.sh
|
||||
|
||||
# Test specific binary
|
||||
./scripts/smoke_test.sh ./path/to/fence
|
||||
./scripts/smoke_test.sh ./path/to/greywall
|
||||
|
||||
# Enable network tests (requires internet)
|
||||
FENCE_TEST_NETWORK=1 ./scripts/smoke_test.sh
|
||||
GREYWALL_TEST_NETWORK=1 ./scripts/smoke_test.sh
|
||||
```
|
||||
|
||||
## Platform-Specific Behavior
|
||||
@@ -158,7 +158,7 @@ The `integration_test.go` file provides helpers for writing sandbox tests:
|
||||
|
||||
```go
|
||||
// Skip helpers
|
||||
skipIfAlreadySandboxed(t) // Skip if running inside Fence
|
||||
skipIfAlreadySandboxed(t) // Skip if running inside Greywall
|
||||
skipIfCommandNotFound(t, "python3") // Skip if command missing
|
||||
|
||||
// Run a command under the sandbox
|
||||
@@ -237,10 +237,10 @@ go test -v -run TestSpecificTest ./internal/sandbox/...
|
||||
|
||||
```bash
|
||||
# Replicate what the test does
|
||||
./fence -c "the-command-that-failed"
|
||||
./greywall -c "the-command-that-failed"
|
||||
|
||||
# With a settings file
|
||||
./fence -s /path/to/settings.json -c "command"
|
||||
./greywall -s /path/to/settings.json -c "command"
|
||||
```
|
||||
|
||||
### Check platform capabilities
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Nested Sandboxing Not Supported
|
||||
|
||||
Fence cannot run inside another sandbox that uses the same underlying technology.
|
||||
Greywall cannot run inside another sandbox that uses the same underlying technology.
|
||||
|
||||
**macOS (Seatbelt)**: If you try to run fence inside an existing `sandbox-exec` sandbox (e.g., Nix's Darwin build sandbox), you'll see:
|
||||
**macOS (Seatbelt)**: If you try to run greywall inside an existing `sandbox-exec` sandbox (e.g., Nix's Darwin build sandbox), you'll see:
|
||||
|
||||
```text
|
||||
Sandbox: sandbox-exec(...) deny(1) forbidden-sandbox-reinit
|
||||
@@ -12,11 +12,11 @@ Sandbox: sandbox-exec(...) deny(1) forbidden-sandbox-reinit
|
||||
|
||||
This is a macOS kernel limitation - nested Seatbelt sandboxes are not allowed. There is no workaround.
|
||||
|
||||
**Linux (Landlock)**: Landlock supports stacking (nested restrictions), but fence's test binaries cannot use the Landlock wrapper (see [Testing docs](testing.md#sandboxed-build-environments-nix-etc)).
|
||||
**Linux (Landlock)**: Landlock supports stacking (nested restrictions), but greywall's test binaries cannot use the Landlock wrapper (see [Testing docs](testing.md#sandboxed-build-environments-nix-etc)).
|
||||
|
||||
## "bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted" (Linux)
|
||||
|
||||
This error occurs when fence tries to create a network namespace but the environment lacks the `CAP_NET_ADMIN` capability. This is common in:
|
||||
This error occurs when greywall tries to create a network namespace but the environment lacks the `CAP_NET_ADMIN` capability. This is common in:
|
||||
|
||||
- **Docker containers** (unless run with `--privileged` or `--cap-add=NET_ADMIN`)
|
||||
- **GitHub Actions** and other CI runners
|
||||
@@ -25,7 +25,7 @@ This error occurs when fence tries to create a network namespace but the environ
|
||||
|
||||
**What happens now:**
|
||||
|
||||
Fence automatically detects this limitation and falls back to running **without network namespace isolation**. The sandbox still provides:
|
||||
Greywall automatically detects this limitation and falls back to running **without network namespace isolation**. The sandbox still provides:
|
||||
|
||||
- Filesystem restrictions (read-only root, allowWrite paths)
|
||||
- PID namespace isolation
|
||||
@@ -41,7 +41,7 @@ Fence automatically detects this limitation and falls back to running **without
|
||||
**To check if your environment supports network namespaces:**
|
||||
|
||||
```bash
|
||||
fence --linux-features
|
||||
greywall --linux-features
|
||||
```
|
||||
|
||||
Look for "Network namespace (--unshare-net): true/false"
|
||||
@@ -51,7 +51,7 @@ Look for "Network namespace (--unshare-net): true/false"
|
||||
1. **Run with elevated privileges:**
|
||||
|
||||
```bash
|
||||
sudo fence <command>
|
||||
sudo greywall <command>
|
||||
```
|
||||
|
||||
2. **In Docker, add capability:**
|
||||
@@ -96,20 +96,20 @@ On most systems with package-manager-installed bwrap, this error shouldn't occur
|
||||
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`.
|
||||
- the request went through greywall's HTTP proxy, which returned `403`.
|
||||
|
||||
Fix:
|
||||
|
||||
- Run with monitor mode to see what was blocked:
|
||||
- `fence -m <command>`
|
||||
- `greywall -m <command>`
|
||||
- Add the required destination(s) to `network.allowedDomains`.
|
||||
|
||||
## "It works outside fence but not inside"
|
||||
## "It works outside greywall but not inside"
|
||||
|
||||
Start with:
|
||||
|
||||
- `fence -m <command>` to see what's being denied
|
||||
- `fence -d <command>` to see full proxy and sandbox detail
|
||||
- `greywall -m <command>` to see what's being denied
|
||||
- `greywall -d <command>` to see full proxy and sandbox detail
|
||||
|
||||
Common causes:
|
||||
|
||||
@@ -134,7 +134,7 @@ const response = await fetch(url, {
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
Greywall'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
|
||||
|
||||
@@ -156,7 +156,7 @@ If you're running a server inside the sandbox that must accept connections:
|
||||
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).
|
||||
- Protect sensitive targets with `filesystem.denyWrite` (and note greywall protects some targets regardless).
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Why Fence?
|
||||
# Why Greywall?
|
||||
|
||||
Fence exists to reduce the blast radius of running commands you don't fully trust (or don't fully understand yet).
|
||||
Greywall exists to reduce the blast radius of running commands you don't fully trust (or don't fully understand yet).
|
||||
|
||||
Common situations:
|
||||
|
||||
@@ -9,11 +9,11 @@ Common situations:
|
||||
- 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`).
|
||||
Greywall 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?"
|
||||
Greywall 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).
|
||||
@@ -21,9 +21,9 @@ Fence helps you answer: "What can this command touch?"
|
||||
|
||||
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
|
||||
## When Greywall is useful even if tools already sandbox
|
||||
|
||||
Some coding agents and platforms ship sandboxing (Seatbelt/Landlock/etc.). Fence still provides value when you want:
|
||||
Some coding agents and platforms ship sandboxing (Seatbelt/Landlock/etc.). Greywall 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.
|
||||
@@ -32,7 +32,7 @@ Some coding agents and platforms ship sandboxing (Seatbelt/Landlock/etc.). Fence
|
||||
|
||||
## Non-goals
|
||||
|
||||
Fence is **not** a hardened containment boundary for actively malicious code.
|
||||
Greywall 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dev Server + Redis Demo
|
||||
|
||||
This demo shows how fence controls network access: allowing specific external domains while blocking (or allowing) localhost connections.
|
||||
This demo shows how greywall controls network access: allowing specific external domains while blocking (or allowing) localhost connections.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -21,7 +21,7 @@ npm install
|
||||
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
|
||||
greywall -p 3000 --settings greywall-external-blocked.json npm start
|
||||
```
|
||||
|
||||
Test it:
|
||||
@@ -39,7 +39,7 @@ curl http://localhost:3000/api/external
|
||||
This shows the opposite: whitelisted external domains work, but Redis (localhost) is blocked.
|
||||
|
||||
```bash
|
||||
fence -p 3000 --settings fence-external-only.json npm start
|
||||
greywall -p 3000 --settings greywall-external-only.json npm start
|
||||
```
|
||||
|
||||
You will immediately notice that Redis connection is blocked on app startup:
|
||||
@@ -62,8 +62,8 @@ curl http://localhost:3000/api/users
|
||||
|
||||
| Config | Redis (localhost) | External (httpbin.org) |
|
||||
|--------|-------------------|------------------------|
|
||||
| `fence-external-blocked.json` | ✓ Allowed | ✗ Blocked |
|
||||
| `fence-external-only.json` | ✗ Blocked | ✓ Allowed |
|
||||
| `greywall-external-blocked.json` | ✓ Allowed | ✗ Blocked |
|
||||
| `greywall-external-only.json` | ✗ Blocked | ✓ Allowed |
|
||||
|
||||
## Key Settings
|
||||
|
||||
@@ -75,7 +75,7 @@ curl http://localhost:3000/api/users
|
||||
|
||||
## 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:
|
||||
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 greywall's proxy:
|
||||
|
||||
```javascript
|
||||
import { ProxyAgent, fetch } from "undici";
|
||||
@@ -86,4 +86,4 @@ const response = await fetch(url, {
|
||||
});
|
||||
```
|
||||
|
||||
Without this, external HTTP requests would fail with connection errors (the sandbox blocks them) rather than going through fence's proxy.
|
||||
Without this, external HTTP requests would fail with connection errors (the sandbox blocks them) rather than going through greywall's proxy.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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)
|
||||
* 3. Attempts to call external APIs (blocked by greywall)
|
||||
*
|
||||
* This demonstrates allowLocalOutbound - the app can reach
|
||||
* local services (Redis) but not the external internet.
|
||||
@@ -60,7 +60,7 @@ async function fetchExternal(url) {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
};
|
||||
|
||||
// Use proxy if available (set by fence)
|
||||
// Use proxy if available (set by greywall)
|
||||
if (proxyUrl) {
|
||||
options.dispatcher = new ProxyAgent(proxyUrl);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ app.get("/", (req, res) => {
|
||||
"/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)",
|
||||
"/api/external": "Try to call external API (blocked by greywall)",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -160,20 +160,20 @@ app.get("/api/external", async (req, res) => {
|
||||
|
||||
try {
|
||||
const result = await fetchExternal("https://httpbin.org/get");
|
||||
// Check if we're using a proxy (indicates fence is running)
|
||||
// Check if we're using a proxy (indicates greywall 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",
|
||||
: "⚠️ No proxy detected - not running in greywall",
|
||||
proxy: usingProxy ? process.env.HTTPS_PROXY : null,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({
|
||||
status: "blocked",
|
||||
message: "✓ External call blocked by fence",
|
||||
message: "✓ External call blocked by greywall",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dev-server-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "Demo: Dev server with Redis in fence sandbox",
|
||||
"description": "Demo: Dev server with Redis in greywall sandbox",
|
||||
"type": "module",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Filesystem Sandbox Demo
|
||||
|
||||
This demo shows how fence controls filesystem access with `allowWrite`, `denyWrite`, and `denyRead`.
|
||||
This demo shows how greywall controls filesystem access with `allowWrite`, `denyWrite`, and `denyRead`.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Operation | Without Fence | With Fence |
|
||||
| Operation | Without Greywall | With Greywall |
|
||||
|-----------|---------------|------------|
|
||||
| Write to `./output/` | ✓ | ✓ (in allowWrite) |
|
||||
| Write to `./` | ✓ | ✗ (not in allowWrite) |
|
||||
@@ -16,19 +16,19 @@ This demo shows how fence controls filesystem access with `allowWrite`, `denyWri
|
||||
|
||||
## Run the demo
|
||||
|
||||
### Without fence (all writes succeed)
|
||||
### Without greywall (all writes succeed)
|
||||
|
||||
```bash
|
||||
python demo.py
|
||||
```
|
||||
|
||||
### With fence (unauthorized operations blocked)
|
||||
### With greywall (unauthorized operations blocked)
|
||||
|
||||
```bash
|
||||
fence --settings fence.json python demo.py
|
||||
greywall --settings greywall.json python demo.py
|
||||
```
|
||||
|
||||
## Fence config
|
||||
## Greywall config
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -58,7 +58,7 @@ fence --settings fence.json python demo.py
|
||||
|
||||
## Protected paths
|
||||
|
||||
Fence also automatically protects certain paths regardless of config:
|
||||
Greywall also automatically protects certain paths regardless of config:
|
||||
|
||||
- Shell configs: `.bashrc`, `.zshrc`, `.profile`
|
||||
- Git hooks: `.git/hooks/*`
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"""
|
||||
Filesystem Sandbox Demo
|
||||
|
||||
This script demonstrates fence's filesystem controls:
|
||||
This script demonstrates greywall'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.
|
||||
Run WITHOUT greywall to see all operations succeed.
|
||||
Run WITH greywall to see unauthorized operations blocked.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -78,7 +78,7 @@ def main():
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ Filesystem Sandbox Demo ║
|
||||
╠═══════════════════════════════════════════════════════════╣
|
||||
║ Tests fence's filesystem controls: ║
|
||||
║ Tests greywall's filesystem controls: ║
|
||||
║ - allowWrite: Only ./output/ is writable ║
|
||||
║ - denyWrite: .env and *.key files are protected ║
|
||||
║ - denyRead: /etc/shadow is blocked ║
|
||||
@@ -96,7 +96,7 @@ def main():
|
||||
"Write to ./output/ (allowed)",
|
||||
)
|
||||
|
||||
# Test 2: Write to project root (should fail with fence)
|
||||
# Test 2: Write to project root (should fail with greywall)
|
||||
try_write(
|
||||
"unauthorized.txt",
|
||||
"This should not be writable.\n",
|
||||
@@ -133,12 +133,12 @@ def main():
|
||||
print(f"({skipped} test(s) skipped - file not found)")
|
||||
|
||||
if blocked > 0:
|
||||
print(f"✅ Fence blocked {blocked} unauthorized operation(s)")
|
||||
print(f"✅ Greywall 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")
|
||||
print("⚠️ All operations succeeded - you are likely not running in greywall")
|
||||
print("Run with: greywall --settings greywall.json python demo.py\n")
|
||||
|
||||
cleanup()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fence Examples
|
||||
# Greywall Examples
|
||||
|
||||
Runnable examples demonstrating `fence` capabilities.
|
||||
Runnable examples demonstrating `greywall` capabilities.
|
||||
|
||||
If you're looking for copy/paste configs and "cookbook" workflows, also see:
|
||||
|
||||
@@ -11,5 +11,5 @@ If you're looking for copy/paste configs and "cookbook" workflows, also see:
|
||||
|
||||
| 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` |
|
||||
| **[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 && greywall -p 3000 --settings greywall-external-blocked.json npm start` |
|
||||
| **[02-filesystem](02-filesystem/README.md)** | Filesystem controls: `allowWrite`, `denyWrite`, `denyRead` | `cd examples/02-filesystem && greywall --settings greywall.json python demo.py` |
|
||||
|
||||
7
go.mod
7
go.mod
@@ -1,20 +1,15 @@
|
||||
module github.com/Use-Tusk/fence
|
||||
module gitea.app.monadical.io/monadical/greywall
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/things-go/go-socks5 v0.0.5
|
||||
github.com/tidwall/jsonc v0.3.2
|
||||
golang.org/x/sys v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
12
go.sum
12
go.sum
@@ -1,28 +1,16 @@
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8=
|
||||
github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ=
|
||||
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
|
||||
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
28
install.sh
28
install.sh
@@ -1,17 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Fence Installer (Linux/macOS only)
|
||||
# Greywall Installer (Linux/macOS only)
|
||||
# For Windows, we recommend using WSL.
|
||||
# Usage (latest):
|
||||
# curl -fsSL https://raw.githubusercontent.com/Use-Tusk/fence/main/install.sh | sh
|
||||
# curl -fsSL https://gitea.app.monadical.io/monadical/greywall/raw/branch/main/install.sh | sh
|
||||
# Usage (specific version):
|
||||
# curl -fsSL https://raw.githubusercontent.com/Use-Tusk/fence/main/install.sh | sh -s -- v0.1.0
|
||||
# curl -fsSL https://gitea.app.monadical.io/monadical/greywall/raw/branch/main/install.sh | sh -s -- v0.1.0
|
||||
# Or via env var:
|
||||
# curl -fsSL https://raw.githubusercontent.com/Use-Tusk/fence/main/install.sh | FENCE_VERSION=0.1.0 sh
|
||||
# curl -fsSL https://gitea.app.monadical.io/monadical/greywall/raw/branch/main/install.sh | GREYWALL_VERSION=0.1.0 sh
|
||||
|
||||
REPO="Use-Tusk/fence"
|
||||
BINARY_NAME="fence"
|
||||
REPO="monadical/greywall"
|
||||
BINARY_NAME="greywall"
|
||||
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
@@ -39,8 +39,8 @@ case "$ARCH" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Determine version to install: use first arg or FENCE_VERSION env var; otherwise fallback to latest
|
||||
REQUESTED_VERSION="${1:-${FENCE_VERSION:-}}"
|
||||
# Determine version to install: use first arg or GREYWALL_VERSION env var; otherwise fallback to latest
|
||||
REQUESTED_VERSION="${1:-${GREYWALL_VERSION:-}}"
|
||||
if [ -n "$REQUESTED_VERSION" ]; then
|
||||
case "$REQUESTED_VERSION" in
|
||||
v*) VERSION_TAG="$REQUESTED_VERSION" ;;
|
||||
@@ -48,11 +48,11 @@ if [ -n "$REQUESTED_VERSION" ]; then
|
||||
esac
|
||||
else
|
||||
# Try manifest first (fast, no rate limits)
|
||||
VERSION_TAG=$(curl -sL "https://use-tusk.github.io/fence/latest.txt" 2>/dev/null || echo "")
|
||||
VERSION_TAG=$(curl -sL "https://gitea.app.monadical.io/monadical/greywall/latest.txt" 2>/dev/null || echo "")
|
||||
|
||||
# Fallback to GitHub API if manifest fails
|
||||
if [ -z "$VERSION_TAG" ]; then
|
||||
VERSION_TAG=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
VERSION_TAG=$(curl -s "https://gitea.app.monadical.io/api/v1/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -75,12 +75,12 @@ TMP_DIR=$(mktemp -d)
|
||||
cd "$TMP_DIR"
|
||||
|
||||
echo "Downloading from $DOWNLOAD_URL..."
|
||||
if ! curl -fsSL -o fence.tar.gz "$DOWNLOAD_URL"; then
|
||||
if ! curl -fsSL -o greywall.tar.gz "$DOWNLOAD_URL"; then
|
||||
echo "Error: Failed to download release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -xzf fence.tar.gz
|
||||
tar -xzf greywall.tar.gz
|
||||
|
||||
# Install to /usr/local/bin or ~/.local/bin
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
@@ -97,9 +97,9 @@ chmod +x "$INSTALL_DIR/$BINARY_NAME"
|
||||
cd - > /dev/null
|
||||
rm -rf "$TMP_DIR"
|
||||
|
||||
echo "Fence $VERSION_TAG installed successfully!"
|
||||
echo "Greywall $VERSION_TAG installed successfully!"
|
||||
echo ""
|
||||
echo "Run 'fence --help' to get started."
|
||||
echo "Run 'greywall --help' to get started."
|
||||
|
||||
# Check if install dir is in PATH
|
||||
case ":$PATH:" in
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Package config defines the configuration types and loading for fence.
|
||||
// Package config defines the configuration types and loading for greywall.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// Config is the main configuration for fence.
|
||||
// Config is the main configuration for greywall.
|
||||
type Config struct {
|
||||
Extends string `json:"extends,omitempty"`
|
||||
Network NetworkConfig `json:"network"`
|
||||
@@ -25,14 +26,12 @@ type Config struct {
|
||||
|
||||
// NetworkConfig defines network restrictions.
|
||||
type NetworkConfig struct {
|
||||
AllowedDomains []string `json:"allowedDomains"`
|
||||
DeniedDomains []string `json:"deniedDomains"`
|
||||
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
|
||||
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
|
||||
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
||||
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
||||
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
||||
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value
|
||||
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
|
||||
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
|
||||
}
|
||||
|
||||
// FilesystemConfig defines filesystem restrictions.
|
||||
@@ -106,13 +105,10 @@ var DefaultDeniedCommands = []string{
|
||||
"nsenter",
|
||||
}
|
||||
|
||||
// Default returns the default configuration with all network blocked.
|
||||
// Default returns the default configuration with all network blocked (no proxy = no network).
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
DeniedDomains: []string{},
|
||||
},
|
||||
Network: NetworkConfig{},
|
||||
Filesystem: FilesystemConfig{
|
||||
DenyRead: []string{},
|
||||
AllowWrite: []string{},
|
||||
@@ -134,12 +130,12 @@ func Default() *Config {
|
||||
|
||||
// DefaultConfigPath returns the default config file path.
|
||||
// Uses the OS-preferred config directory (XDG on Linux, ~/Library/Application Support on macOS).
|
||||
// Falls back to ~/.fence.json if the new location doesn't exist but the legacy one does.
|
||||
// Falls back to ~/.greywall.json if the new location doesn't exist but the legacy one does.
|
||||
func DefaultConfigPath() string {
|
||||
// Try OS-preferred config directory first
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err == nil {
|
||||
newPath := filepath.Join(configDir, "fence", "fence.json")
|
||||
newPath := filepath.Join(configDir, "greywall", "greywall.json")
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
return newPath
|
||||
}
|
||||
@@ -153,18 +149,18 @@ func DefaultConfigPath() string {
|
||||
// Fall back to legacy path if it exists
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "fence.json"
|
||||
return "greywall.json"
|
||||
}
|
||||
legacyPath := filepath.Join(home, ".fence.json")
|
||||
legacyPath := filepath.Join(home, ".greywall.json")
|
||||
if _, err := os.Stat(legacyPath); err == nil {
|
||||
return legacyPath
|
||||
}
|
||||
|
||||
// Neither exists, prefer new XDG-compliant path
|
||||
if configDir != "" {
|
||||
return filepath.Join(configDir, "fence", "fence.json")
|
||||
return filepath.Join(configDir, "greywall", "greywall.json")
|
||||
}
|
||||
return filepath.Join(home, ".config", "fence", "fence.json")
|
||||
return filepath.Join(home, ".config", "greywall", "greywall.json")
|
||||
}
|
||||
|
||||
// Load loads configuration from a file path.
|
||||
@@ -196,14 +192,14 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
// Validate validates the configuration.
|
||||
func (c *Config) Validate() error {
|
||||
for _, domain := range c.Network.AllowedDomains {
|
||||
if err := validateDomainPattern(domain); err != nil {
|
||||
return fmt.Errorf("invalid allowed domain %q: %w", domain, err)
|
||||
if c.Network.ProxyURL != "" {
|
||||
if err := validateProxyURL(c.Network.ProxyURL); err != nil {
|
||||
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
|
||||
}
|
||||
}
|
||||
for _, domain := range c.Network.DeniedDomains {
|
||||
if err := validateDomainPattern(domain); err != nil {
|
||||
return fmt.Errorf("invalid denied domain %q: %w", domain, err)
|
||||
if c.Network.DnsAddr != "" {
|
||||
if err := validateHostPort(c.Network.DnsAddr); err != nil {
|
||||
return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,46 +249,31 @@ func (c *CommandConfig) UseDefaultDeniedCommands() bool {
|
||||
return c.UseDefaults == nil || *c.UseDefaults
|
||||
}
|
||||
|
||||
func validateDomainPattern(pattern string) error {
|
||||
if pattern == "localhost" {
|
||||
return nil
|
||||
// validateProxyURL validates a SOCKS5 proxy URL.
|
||||
func validateProxyURL(proxyURL string) error {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") || strings.Contains(pattern, ":") {
|
||||
return errors.New("domain pattern cannot contain protocol, path, or port")
|
||||
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
|
||||
return errors.New("proxy URL must use socks5:// or socks5h:// scheme")
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
domain := pattern[2:]
|
||||
// Must have at least one more dot after the wildcard
|
||||
if !strings.Contains(domain, ".") {
|
||||
return errors.New("wildcard pattern too broad (e.g., *.com not allowed)")
|
||||
}
|
||||
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
||||
return errors.New("invalid domain format")
|
||||
}
|
||||
// Check each part has content
|
||||
parts := strings.Split(domain, ".")
|
||||
if len(parts) < 2 {
|
||||
return errors.New("wildcard pattern too broad")
|
||||
}
|
||||
if slices.Contains(parts, "") {
|
||||
return errors.New("invalid domain format")
|
||||
}
|
||||
return nil
|
||||
if u.Hostname() == "" {
|
||||
return errors.New("proxy URL must include a hostname")
|
||||
}
|
||||
|
||||
// Reject other uses of wildcards
|
||||
if strings.Contains(pattern, "*") {
|
||||
return errors.New("only *.domain.com wildcard patterns are allowed")
|
||||
if u.Port() == "" {
|
||||
return errors.New("proxy URL must include a port")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular domains must have at least one dot
|
||||
if !strings.Contains(pattern, ".") || strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
|
||||
return errors.New("invalid domain format")
|
||||
// validateHostPort validates a host:port address.
|
||||
func validateHostPort(addr string) error {
|
||||
// Must contain a colon separating host and port
|
||||
host, port, found := strings.Cut(addr, ":")
|
||||
if !found || host == "" || port == "" {
|
||||
return errors.New("must be in host:port format (e.g. localhost:3153)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -332,26 +313,6 @@ func isIPv6Pattern(pattern string) bool {
|
||||
return colonCount >= 2
|
||||
}
|
||||
|
||||
// MatchesDomain checks if a hostname matches a domain pattern.
|
||||
func MatchesDomain(hostname, pattern string) bool {
|
||||
hostname = strings.ToLower(hostname)
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
// "*" matches all domains
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard pattern like *.example.com
|
||||
if strings.HasPrefix(pattern, "*.") {
|
||||
baseDomain := pattern[2:]
|
||||
return strings.HasSuffix(hostname, "."+baseDomain)
|
||||
}
|
||||
|
||||
// Exact match
|
||||
return hostname == pattern
|
||||
}
|
||||
|
||||
// MatchesHost checks if a hostname matches an SSH host pattern.
|
||||
// SSH host patterns support wildcards anywhere in the pattern.
|
||||
func MatchesHost(hostname, pattern string) bool {
|
||||
@@ -440,9 +401,11 @@ func Merge(base, override *Config) *Config {
|
||||
AllowPty: base.AllowPty || override.AllowPty,
|
||||
|
||||
Network: NetworkConfig{
|
||||
// ProxyURL/DnsAddr: override wins if non-empty
|
||||
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
|
||||
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
|
||||
|
||||
// Append slices (base first, then override additions)
|
||||
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
|
||||
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
|
||||
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
|
||||
|
||||
// Boolean fields: override wins if set, otherwise base
|
||||
@@ -451,10 +414,6 @@ func Merge(base, override *Config) *Config {
|
||||
|
||||
// Pointer fields: override wins if set, otherwise base
|
||||
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
|
||||
|
||||
// Port fields: override wins if non-zero
|
||||
HTTPProxyPort: mergeInt(base.Network.HTTPProxyPort, override.Network.HTTPProxyPort),
|
||||
SOCKSProxyPort: mergeInt(base.Network.SOCKSProxyPort, override.Network.SOCKSProxyPort),
|
||||
},
|
||||
|
||||
Filesystem: FilesystemConfig{
|
||||
@@ -531,9 +490,9 @@ func mergeOptionalBool(base, override *bool) *bool {
|
||||
return base
|
||||
}
|
||||
|
||||
// mergeInt returns override if non-zero, otherwise base.
|
||||
func mergeInt(base, override int) int {
|
||||
if override != 0 {
|
||||
// mergeString returns override if non-empty, otherwise base.
|
||||
func mergeString(base, override string) string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
return base
|
||||
|
||||
@@ -6,72 +6,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateDomainPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid patterns
|
||||
{"valid domain", "example.com", false},
|
||||
{"valid subdomain", "api.example.com", false},
|
||||
{"valid wildcard", "*.example.com", false},
|
||||
{"valid wildcard subdomain", "*.api.example.com", false},
|
||||
{"localhost", "localhost", false},
|
||||
|
||||
// Invalid patterns
|
||||
{"protocol included", "https://example.com", true},
|
||||
{"path included", "example.com/path", true},
|
||||
{"port included", "example.com:443", true},
|
||||
{"wildcard too broad", "*.com", true},
|
||||
{"invalid wildcard position", "example.*.com", true},
|
||||
{"trailing wildcard", "example.com.*", true},
|
||||
{"leading dot", ".example.com", true},
|
||||
{"trailing dot", "example.com.", true},
|
||||
{"no TLD", "example", true},
|
||||
{"empty wildcard domain part", "*.", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateDomainPattern(tt.pattern)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateDomainPattern(%q) error = %v, wantErr %v", tt.pattern, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
// Exact matches
|
||||
{"exact match", "example.com", "example.com", true},
|
||||
{"exact match case insensitive", "Example.COM", "example.com", true},
|
||||
{"exact no match", "other.com", "example.com", false},
|
||||
|
||||
// Wildcard matches
|
||||
{"wildcard match subdomain", "api.example.com", "*.example.com", true},
|
||||
{"wildcard match deep subdomain", "deep.api.example.com", "*.example.com", true},
|
||||
{"wildcard no match base domain", "example.com", "*.example.com", false},
|
||||
{"wildcard no match different domain", "api.other.com", "*.example.com", false},
|
||||
{"wildcard case insensitive", "API.Example.COM", "*.example.com", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := MatchesDomain(tt.hostname, tt.pattern)
|
||||
if got != tt.want {
|
||||
t.Errorf("MatchesDomain(%q, %q) = %v, want %v", tt.hostname, tt.pattern, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -84,29 +18,55 @@ func TestConfigValidate(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with domains",
|
||||
name: "valid config with proxy",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com", "*.github.com"},
|
||||
DeniedDomains: []string{"blocked.com"},
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid allowed domain",
|
||||
name: "valid socks5h proxy",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"https://example.com"},
|
||||
ProxyURL: "socks5h://proxy.example.com:1080",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid proxy - wrong scheme",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
ProxyURL: "http://localhost:1080",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid denied domain",
|
||||
name: "invalid proxy - no port",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
DeniedDomains: []string{"*.com"},
|
||||
ProxyURL: "socks5://localhost",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid proxy - no host",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
ProxyURL: "socks5://:1080",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid proxy - not a URL",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
ProxyURL: "not-a-url",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
@@ -164,11 +124,8 @@ func TestDefault(t *testing.T) {
|
||||
if cfg == nil {
|
||||
t.Fatal("Default() returned nil")
|
||||
}
|
||||
if cfg.Network.AllowedDomains == nil {
|
||||
t.Error("AllowedDomains should not be nil")
|
||||
}
|
||||
if cfg.Network.DeniedDomains == nil {
|
||||
t.Error("DeniedDomains should not be nil")
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
t.Error("ProxyURL should be empty by default")
|
||||
}
|
||||
if cfg.Filesystem.DenyRead == nil {
|
||||
t.Error("DenyRead should not be nil")
|
||||
@@ -222,21 +179,18 @@ func TestLoad(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config",
|
||||
name: "valid config with proxy",
|
||||
setup: func(dir string) string {
|
||||
path := filepath.Join(dir, "valid.json")
|
||||
content := `{"network":{"allowedDomains":["example.com"]}}`
|
||||
content := `{"network":{"proxyUrl":"socks5://localhost:1080"}}`
|
||||
_ = os.WriteFile(path, []byte(content), 0o600)
|
||||
return path
|
||||
},
|
||||
wantNil: false,
|
||||
wantErr: false,
|
||||
checkConfig: func(t *testing.T, cfg *Config) {
|
||||
if len(cfg.Network.AllowedDomains) != 1 {
|
||||
t.Errorf("expected 1 allowed domain, got %d", len(cfg.Network.AllowedDomains))
|
||||
}
|
||||
if cfg.Network.AllowedDomains[0] != "example.com" {
|
||||
t.Errorf("expected example.com, got %s", cfg.Network.AllowedDomains[0])
|
||||
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Errorf("expected socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -251,10 +205,10 @@ func TestLoad(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid domain in config",
|
||||
name: "invalid proxy URL in config",
|
||||
setup: func(dir string) string {
|
||||
path := filepath.Join(dir, "invalid_domain.json")
|
||||
content := `{"network":{"allowedDomains":["*.com"]}}`
|
||||
path := filepath.Join(dir, "invalid_proxy.json")
|
||||
content := `{"network":{"proxyUrl":"http://localhost:1080"}}`
|
||||
_ = os.WriteFile(path, []byte(content), 0o600)
|
||||
return path
|
||||
},
|
||||
@@ -295,10 +249,10 @@ func TestDefaultConfigPath(t *testing.T) {
|
||||
if path == "" {
|
||||
t.Error("DefaultConfigPath() returned empty string")
|
||||
}
|
||||
// Should end with fence.json (either new XDG path or legacy .fence.json)
|
||||
// Should end with greywall.json (either new XDG path or legacy .greywall.json)
|
||||
base := filepath.Base(path)
|
||||
if base != "fence.json" && base != ".fence.json" {
|
||||
t.Errorf("DefaultConfigPath() = %q, expected to end with fence.json or .fence.json", path)
|
||||
if base != "greywall.json" && base != ".greywall.json" {
|
||||
t.Errorf("DefaultConfigPath() = %q, expected to end with greywall.json or .greywall.json", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,15 +261,15 @@ func TestMerge(t *testing.T) {
|
||||
override := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
}
|
||||
result := Merge(nil, override)
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true")
|
||||
}
|
||||
if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "example.com" {
|
||||
t.Error("expected AllowedDomains to be [example.com]")
|
||||
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Error("expected ProxyURL to be socks5://localhost:1080")
|
||||
}
|
||||
if result.Extends != "" {
|
||||
t.Error("expected Extends to be cleared")
|
||||
@@ -326,15 +280,15 @@ func TestMerge(t *testing.T) {
|
||||
base := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
}
|
||||
result := Merge(base, nil)
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true")
|
||||
}
|
||||
if len(result.Network.AllowedDomains) != 1 {
|
||||
t.Error("expected AllowedDomains to be [example.com]")
|
||||
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Error("expected ProxyURL to be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -345,47 +299,37 @@ func TestMerge(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge allowed domains", func(t *testing.T) {
|
||||
t.Run("proxy URL override wins", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "api.github.com"},
|
||||
ProxyURL: "socks5://base:1080",
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Extends: "base-template",
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"private-registry.company.com"},
|
||||
ProxyURL: "socks5://override:1080",
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
// Should have all three domains
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 allowed domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
|
||||
// Extends should be cleared
|
||||
if result.Extends != "" {
|
||||
t.Errorf("expected Extends to be cleared, got %q", result.Extends)
|
||||
if result.Network.ProxyURL != "socks5://override:1080" {
|
||||
t.Errorf("expected override ProxyURL, got %s", result.Network.ProxyURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicate merged domains", func(t *testing.T) {
|
||||
t.Run("proxy URL base preserved when override empty", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "example.com"},
|
||||
ProxyURL: "socks5://base:1080",
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "new.com"},
|
||||
},
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
// Should deduplicate
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 domains (deduped), got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
if result.Network.ProxyURL != "socks5://base:1080" {
|
||||
t.Errorf("expected base ProxyURL, got %s", result.Network.ProxyURL)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -484,51 +428,6 @@ func TestMerge(t *testing.T) {
|
||||
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merge defaultDenyRead from override", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
DefaultDenyRead: false,
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Filesystem: FilesystemConfig{
|
||||
DefaultDenyRead: true,
|
||||
AllowRead: []string{"/home/user/project"},
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if !result.Filesystem.DefaultDenyRead {
|
||||
t.Error("expected DefaultDenyRead to be true (from override)")
|
||||
}
|
||||
if len(result.Filesystem.AllowRead) != 1 {
|
||||
t.Errorf("expected 1 allowRead path, got %d", len(result.Filesystem.AllowRead))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("override ports", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
HTTPProxyPort: 9090, // override
|
||||
// SOCKSProxyPort not set, should keep base
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
if result.Network.HTTPProxyPort != 9090 {
|
||||
t.Errorf("expected HTTPProxyPort 9090, got %d", result.Network.HTTPProxyPort)
|
||||
}
|
||||
if result.Network.SOCKSProxyPort != 1080 {
|
||||
t.Errorf("expected SOCKSProxyPort 1080, got %d", result.Network.SOCKSProxyPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
@@ -741,3 +640,30 @@ func TestMergeSSHConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateProxyURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid socks5", "socks5://localhost:1080", false},
|
||||
{"valid socks5h", "socks5h://proxy.example.com:1080", false},
|
||||
{"valid socks5 with ip", "socks5://192.168.1.1:1080", false},
|
||||
{"http scheme", "http://localhost:1080", true},
|
||||
{"https scheme", "https://localhost:1080", true},
|
||||
{"no port", "socks5://localhost", true},
|
||||
{"no host", "socks5://:1080", true},
|
||||
{"not a URL", "not-a-url", true},
|
||||
{"empty", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateProxyURL(tt.url)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateProxyURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
// Package importer provides functionality to import settings from other tools.
|
||||
package importer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// ClaudeSettings represents the Claude Code settings.json structure.
|
||||
type ClaudeSettings struct {
|
||||
Permissions ClaudePermissions `json:"permissions"`
|
||||
}
|
||||
|
||||
// ClaudePermissions represents the permissions block in Claude Code settings.
|
||||
type ClaudePermissions struct {
|
||||
Allow []string `json:"allow"`
|
||||
Deny []string `json:"deny"`
|
||||
Ask []string `json:"ask"`
|
||||
}
|
||||
|
||||
// ClaudeSettingsPaths returns the standard paths where Claude Code stores settings.
|
||||
func ClaudeSettingsPaths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := []string{
|
||||
filepath.Join(home, ".claude", "settings.json"),
|
||||
}
|
||||
|
||||
// Also check project-level settings in current directory
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
paths = append(paths,
|
||||
filepath.Join(cwd, ".claude", "settings.json"),
|
||||
filepath.Join(cwd, ".claude", "settings.local.json"),
|
||||
)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// DefaultClaudeSettingsPath returns the default user-level Claude settings path.
|
||||
func DefaultClaudeSettingsPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".claude", "settings.json")
|
||||
}
|
||||
|
||||
// LoadClaudeSettings loads Claude Code settings from a file.
|
||||
func LoadClaudeSettings(path string) (*ClaudeSettings, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // user-provided path - intentional
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Claude settings: %w", err)
|
||||
}
|
||||
|
||||
// Handle empty file
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return &ClaudeSettings{}, nil
|
||||
}
|
||||
|
||||
var settings ClaudeSettings
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &settings); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in Claude settings: %w", err)
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// ConvertClaudeToFence converts Claude Code settings to a fence config.
|
||||
func ConvertClaudeToFence(settings *ClaudeSettings) *config.Config {
|
||||
cfg := config.Default()
|
||||
|
||||
// Process allow rules
|
||||
for _, rule := range settings.Permissions.Allow {
|
||||
processClaudeRule(rule, cfg, true)
|
||||
}
|
||||
|
||||
// Process deny rules
|
||||
for _, rule := range settings.Permissions.Deny {
|
||||
processClaudeRule(rule, cfg, false)
|
||||
}
|
||||
|
||||
// Process ask rules (treat as deny for fence, since fence doesn't have interactive prompts)
|
||||
// Users can review and move to allow if needed
|
||||
for _, rule := range settings.Permissions.Ask {
|
||||
processClaudeRule(rule, cfg, false)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// bashPattern matches Bash permission rules like "Bash(npm run test:*)" or "Bash(curl:*)"
|
||||
var bashPattern = regexp.MustCompile(`^Bash\((.+)\)$`)
|
||||
|
||||
// readPattern matches Read permission rules like "Read(./.env)" or "Read(./secrets/**)"
|
||||
var readPattern = regexp.MustCompile(`^Read\((.+)\)$`)
|
||||
|
||||
// writePattern matches Write permission rules like "Write(./output/**)"
|
||||
var writePattern = regexp.MustCompile(`^Write\((.+)\)$`)
|
||||
|
||||
// editPattern matches Edit permission rules (similar to Write)
|
||||
var editPattern = regexp.MustCompile(`^Edit\((.+)\)$`)
|
||||
|
||||
// processClaudeRule processes a single Claude permission rule and updates the fence config.
|
||||
func processClaudeRule(rule string, cfg *config.Config, isAllow bool) {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Bash(command) rules
|
||||
if matches := bashPattern.FindStringSubmatch(rule); len(matches) == 2 {
|
||||
cmd := normalizeClaudeCommand(matches[1])
|
||||
if cmd != "" {
|
||||
if isAllow {
|
||||
cfg.Command.Allow = appendUnique(cfg.Command.Allow, cmd)
|
||||
} else {
|
||||
cfg.Command.Deny = appendUnique(cfg.Command.Deny, cmd)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Read(path) rules
|
||||
if matches := readPattern.FindStringSubmatch(rule); len(matches) == 2 {
|
||||
path := normalizeClaudePath(matches[1])
|
||||
if path != "" {
|
||||
if !isAllow {
|
||||
// Read deny -> filesystem.denyRead
|
||||
cfg.Filesystem.DenyRead = appendUnique(cfg.Filesystem.DenyRead, path)
|
||||
}
|
||||
// Note: fence doesn't have an "allowRead" concept - everything is readable by default
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Write(path) rules
|
||||
if matches := writePattern.FindStringSubmatch(rule); len(matches) == 2 {
|
||||
path := normalizeClaudePath(matches[1])
|
||||
if path != "" {
|
||||
if isAllow {
|
||||
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
|
||||
} else {
|
||||
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Edit(path) rules (same as Write)
|
||||
if matches := editPattern.FindStringSubmatch(rule); len(matches) == 2 {
|
||||
path := normalizeClaudePath(matches[1])
|
||||
if path != "" {
|
||||
if isAllow {
|
||||
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
|
||||
} else {
|
||||
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle bare tool names (e.g., "Read", "Write", "Bash")
|
||||
// These are global permissions that don't map directly to fence's path-based model
|
||||
// We skip them as they don't provide actionable path/command restrictions
|
||||
}
|
||||
|
||||
// normalizeClaudeCommand converts Claude's command format to fence format.
|
||||
// Claude uses "npm:*" style, fence uses "npm" for prefix matching.
|
||||
func normalizeClaudeCommand(cmd string) string {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
// Handle wildcard patterns like "npm:*" -> "npm"
|
||||
// Claude uses ":" as separator, fence uses space-separated commands
|
||||
// Also handles "npm run test:*" -> "npm run test"
|
||||
cmd = strings.TrimSuffix(cmd, ":*")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// normalizeClaudePath converts Claude's path format to fence format.
|
||||
func normalizeClaudePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
// Claude uses ./ prefix for relative paths, fence doesn't require it
|
||||
// but fence does support it, so we can keep it
|
||||
|
||||
// Convert ** glob patterns - both Claude and fence support these
|
||||
// No conversion needed
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// appendUnique appends a value to a slice if it's not already present.
|
||||
func appendUnique(slice []string, value string) []string {
|
||||
for _, v := range slice {
|
||||
if v == value {
|
||||
return slice
|
||||
}
|
||||
}
|
||||
return append(slice, value)
|
||||
}
|
||||
|
||||
// ImportResult contains the result of an import operation.
|
||||
type ImportResult struct {
|
||||
Config *config.Config
|
||||
SourcePath string
|
||||
RulesImported int
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// ImportOptions configures the import behavior.
|
||||
type ImportOptions struct {
|
||||
// Extends specifies a template or file to extend. Empty string means no extends.
|
||||
Extends string
|
||||
}
|
||||
|
||||
// DefaultImportOptions returns the default import options.
|
||||
// By default, imports extend the "code" template for sensible defaults.
|
||||
func DefaultImportOptions() ImportOptions {
|
||||
return ImportOptions{
|
||||
Extends: "code",
|
||||
}
|
||||
}
|
||||
|
||||
// ImportFromClaude imports settings from Claude Code and returns a fence config.
|
||||
// If path is empty, it tries the default Claude settings path.
|
||||
func ImportFromClaude(path string, opts ImportOptions) (*ImportResult, error) {
|
||||
if path == "" {
|
||||
path = DefaultClaudeSettingsPath()
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("could not determine Claude settings path")
|
||||
}
|
||||
|
||||
settings, err := LoadClaudeSettings(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := ConvertClaudeToFence(settings)
|
||||
|
||||
// Set extends if specified
|
||||
if opts.Extends != "" {
|
||||
cfg.Extends = opts.Extends
|
||||
}
|
||||
|
||||
result := &ImportResult{
|
||||
Config: cfg,
|
||||
SourcePath: path,
|
||||
RulesImported: len(settings.Permissions.Allow) +
|
||||
len(settings.Permissions.Deny) +
|
||||
len(settings.Permissions.Ask),
|
||||
}
|
||||
|
||||
// Add warnings for rules that couldn't be fully converted
|
||||
for _, rule := range settings.Permissions.Allow {
|
||||
if isGlobalToolRule(rule) {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
|
||||
}
|
||||
}
|
||||
for _, rule := range settings.Permissions.Deny {
|
||||
if isGlobalToolRule(rule) {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
|
||||
}
|
||||
}
|
||||
for _, rule := range settings.Permissions.Ask {
|
||||
if isGlobalToolRule(rule) {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
|
||||
} else {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("Ask rule %q converted to deny (fence doesn't support interactive prompts)", rule))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isGlobalToolRule checks if a rule is a global tool permission (no path/command specified).
|
||||
func isGlobalToolRule(rule string) bool {
|
||||
rule = strings.TrimSpace(rule)
|
||||
// Global rules are bare tool names without parentheses
|
||||
return !strings.Contains(rule, "(")
|
||||
}
|
||||
|
||||
// cleanNetworkConfig is used for JSON output with omitempty to skip empty fields.
|
||||
type cleanNetworkConfig struct {
|
||||
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
||||
DeniedDomains []string `json:"deniedDomains,omitempty"`
|
||||
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
||||
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
||||
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
||||
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"`
|
||||
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
|
||||
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
|
||||
}
|
||||
|
||||
// cleanFilesystemConfig is used for JSON output with omitempty to skip empty fields.
|
||||
type cleanFilesystemConfig struct {
|
||||
DenyRead []string `json:"denyRead,omitempty"`
|
||||
AllowWrite []string `json:"allowWrite,omitempty"`
|
||||
DenyWrite []string `json:"denyWrite,omitempty"`
|
||||
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
||||
}
|
||||
|
||||
// cleanCommandConfig is used for JSON output with omitempty to skip empty fields.
|
||||
type cleanCommandConfig struct {
|
||||
Deny []string `json:"deny,omitempty"`
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
UseDefaults *bool `json:"useDefaults,omitempty"`
|
||||
}
|
||||
|
||||
// cleanConfig is used for JSON output with fields in desired order and omitempty.
|
||||
type cleanConfig struct {
|
||||
Extends string `json:"extends,omitempty"`
|
||||
AllowPty bool `json:"allowPty,omitempty"`
|
||||
Network *cleanNetworkConfig `json:"network,omitempty"`
|
||||
Filesystem *cleanFilesystemConfig `json:"filesystem,omitempty"`
|
||||
Command *cleanCommandConfig `json:"command,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalConfigJSON marshals a fence config to clean JSON, omitting empty arrays
|
||||
// and with fields in a logical order (extends first).
|
||||
func MarshalConfigJSON(cfg *config.Config) ([]byte, error) {
|
||||
clean := cleanConfig{
|
||||
Extends: cfg.Extends,
|
||||
AllowPty: cfg.AllowPty,
|
||||
}
|
||||
|
||||
// Network config - only include if non-empty
|
||||
network := cleanNetworkConfig{
|
||||
AllowedDomains: cfg.Network.AllowedDomains,
|
||||
DeniedDomains: cfg.Network.DeniedDomains,
|
||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: cfg.Network.AllowLocalBinding,
|
||||
AllowLocalOutbound: cfg.Network.AllowLocalOutbound,
|
||||
HTTPProxyPort: cfg.Network.HTTPProxyPort,
|
||||
SOCKSProxyPort: cfg.Network.SOCKSProxyPort,
|
||||
}
|
||||
if !isNetworkEmpty(network) {
|
||||
clean.Network = &network
|
||||
}
|
||||
|
||||
// Filesystem config - only include if non-empty
|
||||
filesystem := cleanFilesystemConfig{
|
||||
DenyRead: cfg.Filesystem.DenyRead,
|
||||
AllowWrite: cfg.Filesystem.AllowWrite,
|
||||
DenyWrite: cfg.Filesystem.DenyWrite,
|
||||
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
|
||||
}
|
||||
if !isFilesystemEmpty(filesystem) {
|
||||
clean.Filesystem = &filesystem
|
||||
}
|
||||
|
||||
// Command config - only include if non-empty
|
||||
command := cleanCommandConfig{
|
||||
Deny: cfg.Command.Deny,
|
||||
Allow: cfg.Command.Allow,
|
||||
UseDefaults: cfg.Command.UseDefaults,
|
||||
}
|
||||
if !isCommandEmpty(command) {
|
||||
clean.Command = &command
|
||||
}
|
||||
|
||||
return json.MarshalIndent(clean, "", " ")
|
||||
}
|
||||
|
||||
func isNetworkEmpty(n cleanNetworkConfig) bool {
|
||||
return len(n.AllowedDomains) == 0 &&
|
||||
len(n.DeniedDomains) == 0 &&
|
||||
len(n.AllowUnixSockets) == 0 &&
|
||||
!n.AllowAllUnixSockets &&
|
||||
!n.AllowLocalBinding &&
|
||||
n.AllowLocalOutbound == nil &&
|
||||
n.HTTPProxyPort == 0 &&
|
||||
n.SOCKSProxyPort == 0
|
||||
}
|
||||
|
||||
func isFilesystemEmpty(f cleanFilesystemConfig) bool {
|
||||
return len(f.DenyRead) == 0 &&
|
||||
len(f.AllowWrite) == 0 &&
|
||||
len(f.DenyWrite) == 0 &&
|
||||
!f.AllowGitConfig
|
||||
}
|
||||
|
||||
func isCommandEmpty(c cleanCommandConfig) bool {
|
||||
return len(c.Deny) == 0 &&
|
||||
len(c.Allow) == 0 &&
|
||||
c.UseDefaults == nil
|
||||
}
|
||||
|
||||
// FormatConfigWithComment returns the config JSON with a comment header
|
||||
// explaining that values are inherited from the extended template.
|
||||
func FormatConfigWithComment(cfg *config.Config) (string, error) {
|
||||
data, err := MarshalConfigJSON(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Add comment about inherited values if extending a template
|
||||
if cfg.Extends != "" {
|
||||
output.WriteString(fmt.Sprintf("// This config extends %q.\n", cfg.Extends))
|
||||
output.WriteString(fmt.Sprintf("// Network, filesystem, and command rules from %q are inherited.\n", cfg.Extends))
|
||||
output.WriteString("// Only your additional rules are shown below.\n")
|
||||
output.WriteString("// Run `fence --list-templates` to see available templates.\n")
|
||||
}
|
||||
|
||||
output.Write(data)
|
||||
output.WriteByte('\n')
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
// WriteConfig writes a fence config to a file.
|
||||
func WriteConfig(cfg *config.Config, path string) error {
|
||||
output, err := FormatConfigWithComment(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(output), 0o644); err != nil { //nolint:gosec // config file permissions
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertClaudeToFence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings *ClaudeSettings
|
||||
wantCmd struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}
|
||||
wantFS struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "empty settings",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{},
|
||||
},
|
||||
wantCmd: struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}{},
|
||||
wantFS: struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}{},
|
||||
},
|
||||
{
|
||||
name: "bash allow rules",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Allow: []string{
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run test:*)",
|
||||
"Bash(git status)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmd: struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}{
|
||||
allow: []string{"npm run lint", "npm run test", "git status"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bash deny rules",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Deny: []string{
|
||||
"Bash(curl:*)",
|
||||
"Bash(sudo:*)",
|
||||
"Bash(rm -rf /)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmd: struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}{
|
||||
deny: []string{"curl", "sudo", "rm -rf /"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read deny rules",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Deny: []string{
|
||||
"Read(./.env)",
|
||||
"Read(./secrets/**)",
|
||||
"Read(~/.ssh/*)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantFS: struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}{
|
||||
denyRead: []string{"./.env", "./secrets/**", "~/.ssh/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write allow rules",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Allow: []string{
|
||||
"Write(./output/**)",
|
||||
"Write(./build)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantFS: struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}{
|
||||
allowWrite: []string{"./output/**", "./build"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write deny rules",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Deny: []string{
|
||||
"Write(./.git/**)",
|
||||
"Edit(./package-lock.json)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantFS: struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}{
|
||||
denyWrite: []string{"./.git/**", "./package-lock.json"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ask rules converted to deny",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Ask: []string{
|
||||
"Write(./config.json)",
|
||||
"Bash(npm publish)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmd: struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}{
|
||||
deny: []string{"npm publish"},
|
||||
},
|
||||
wantFS: struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}{
|
||||
denyWrite: []string{"./config.json"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "global tool rules are skipped",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Allow: []string{
|
||||
"Read",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Bash(npm run build)", // This should be included
|
||||
},
|
||||
Deny: []string{
|
||||
"Edit",
|
||||
"Bash(sudo:*)", // This should be included
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmd: struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}{
|
||||
allow: []string{"npm run build"},
|
||||
deny: []string{"sudo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed rules",
|
||||
settings: &ClaudeSettings{
|
||||
Permissions: ClaudePermissions{
|
||||
Allow: []string{
|
||||
"Bash(npm install)",
|
||||
"Bash(npm run:*)",
|
||||
"Write(./dist/**)",
|
||||
},
|
||||
Deny: []string{
|
||||
"Bash(curl:*)",
|
||||
"Read(./.env)",
|
||||
"Write(./.git/**)",
|
||||
},
|
||||
Ask: []string{
|
||||
"Bash(git push)",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmd: struct {
|
||||
allow []string
|
||||
deny []string
|
||||
}{
|
||||
allow: []string{"npm install", "npm run"},
|
||||
deny: []string{"curl", "git push"},
|
||||
},
|
||||
wantFS: struct {
|
||||
denyRead []string
|
||||
allowWrite []string
|
||||
denyWrite []string
|
||||
}{
|
||||
denyRead: []string{"./.env"},
|
||||
allowWrite: []string{"./dist/**"},
|
||||
denyWrite: []string{"./.git/**"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := ConvertClaudeToFence(tt.settings)
|
||||
|
||||
assert.ElementsMatch(t, tt.wantCmd.allow, cfg.Command.Allow, "command.allow mismatch")
|
||||
assert.ElementsMatch(t, tt.wantCmd.deny, cfg.Command.Deny, "command.deny mismatch")
|
||||
assert.ElementsMatch(t, tt.wantFS.denyRead, cfg.Filesystem.DenyRead, "filesystem.denyRead mismatch")
|
||||
assert.ElementsMatch(t, tt.wantFS.allowWrite, cfg.Filesystem.AllowWrite, "filesystem.allowWrite mismatch")
|
||||
assert.ElementsMatch(t, tt.wantFS.denyWrite, cfg.Filesystem.DenyWrite, "filesystem.denyWrite mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClaudeCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"npm:*", "npm"},
|
||||
{"curl:*", "curl"},
|
||||
{"npm run test:*", "npm run test"},
|
||||
{"git status", "git status"},
|
||||
{"sudo rm -rf", "sudo rm -rf"},
|
||||
{"", ""},
|
||||
{" npm ", "npm"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeClaudeCommand(tt.input)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClaudeSettings(t *testing.T) {
|
||||
t.Run("valid settings", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
content := `{
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm install)", "Read"],
|
||||
"deny": ["Bash(sudo:*)"],
|
||||
"ask": ["Write"]
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
settings, err := LoadClaudeSettings(settingsPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"Bash(npm install)", "Read"}, settings.Permissions.Allow)
|
||||
assert.Equal(t, []string{"Bash(sudo:*)"}, settings.Permissions.Deny)
|
||||
assert.Equal(t, []string{"Write"}, settings.Permissions.Ask)
|
||||
})
|
||||
|
||||
t.Run("settings with comments (JSONC)", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
content := `{
|
||||
// This is a comment
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm install)"],
|
||||
"deny": [], // Another comment
|
||||
"ask": []
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
settings, err := LoadClaudeSettings(settingsPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []string{"Bash(npm install)"}, settings.Permissions.Allow)
|
||||
})
|
||||
|
||||
t.Run("empty file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
err := os.WriteFile(settingsPath, []byte(""), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
settings, err := LoadClaudeSettings(settingsPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, settings)
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
_, err := LoadClaudeSettings("/nonexistent/path/settings.json")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
err := os.WriteFile(settingsPath, []byte("not json"), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = LoadClaudeSettings(settingsPath)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestImportFromClaude(t *testing.T) {
|
||||
t.Run("successful import with default extends", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
content := `{
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm install)", "Write(./dist/**)"],
|
||||
"deny": ["Bash(curl:*)", "Read(./.env)"],
|
||||
"ask": ["Bash(git push)"]
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, settingsPath, result.SourcePath)
|
||||
assert.Equal(t, 5, result.RulesImported)
|
||||
assert.Equal(t, "code", result.Config.Extends) // default extends
|
||||
|
||||
// Check converted config
|
||||
assert.Contains(t, result.Config.Command.Allow, "npm install")
|
||||
assert.Contains(t, result.Config.Command.Deny, "curl")
|
||||
assert.Contains(t, result.Config.Command.Deny, "git push") // ask -> deny
|
||||
assert.Contains(t, result.Config.Filesystem.AllowWrite, "./dist/**")
|
||||
assert.Contains(t, result.Config.Filesystem.DenyRead, "./.env")
|
||||
})
|
||||
|
||||
t.Run("import with no extend", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
content := `{
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm install)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := ImportOptions{Extends: ""}
|
||||
result, err := ImportFromClaude(settingsPath, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", result.Config.Extends) // no extends
|
||||
assert.Contains(t, result.Config.Command.Allow, "npm install")
|
||||
})
|
||||
|
||||
t.Run("import with custom extend", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
content := `{
|
||||
"permissions": {
|
||||
"allow": ["Bash(npm install)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := ImportOptions{Extends: "local-dev-server"}
|
||||
result, err := ImportFromClaude(settingsPath, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "local-dev-server", result.Config.Extends)
|
||||
})
|
||||
|
||||
t.Run("warnings for global rules", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
||||
|
||||
content := `{
|
||||
"permissions": {
|
||||
"allow": ["Read", "Grep", "Bash(npm install)"],
|
||||
"deny": ["Edit"],
|
||||
"ask": ["Write"]
|
||||
}
|
||||
}`
|
||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have warnings for global rules: Read, Grep, Edit, Write (all global)
|
||||
assert.Len(t, result.Warnings, 4)
|
||||
|
||||
// Verify the warnings mention the right rules
|
||||
warningsStr := strings.Join(result.Warnings, " ")
|
||||
assert.Contains(t, warningsStr, "Read")
|
||||
assert.Contains(t, warningsStr, "Grep")
|
||||
assert.Contains(t, warningsStr, "Edit")
|
||||
assert.Contains(t, warningsStr, "Write")
|
||||
assert.Contains(t, warningsStr, "skipped")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "fence.json")
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Command.Allow = []string{"npm install"}
|
||||
cfg.Command.Deny = []string{"curl"}
|
||||
cfg.Filesystem.DenyRead = []string{"./.env"}
|
||||
|
||||
err := WriteConfig(cfg, outputPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the file was written correctly
|
||||
data, err := os.ReadFile(outputPath) //nolint:gosec // test reads file we just wrote
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, string(data), `"npm install"`)
|
||||
assert.Contains(t, string(data), `"curl"`)
|
||||
assert.Contains(t, string(data), `"./.env"`)
|
||||
}
|
||||
|
||||
func TestMarshalConfigJSON(t *testing.T) {
|
||||
t.Run("omits empty arrays", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Command.Allow = []string{"npm install"}
|
||||
// Leave all other arrays empty
|
||||
|
||||
data, err := MarshalConfigJSON(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := string(data)
|
||||
assert.Contains(t, output, `"npm install"`)
|
||||
assert.NotContains(t, output, `"allowedDomains"`)
|
||||
assert.NotContains(t, output, `"deniedDomains"`)
|
||||
assert.NotContains(t, output, `"denyRead"`)
|
||||
assert.NotContains(t, output, `"allowWrite"`)
|
||||
assert.NotContains(t, output, `"denyWrite"`)
|
||||
assert.NotContains(t, output, `"network"`) // entire network section should be omitted
|
||||
})
|
||||
|
||||
t.Run("includes extends field", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Extends = "code"
|
||||
cfg.Command.Allow = []string{"npm install"}
|
||||
|
||||
data, err := MarshalConfigJSON(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := string(data)
|
||||
assert.Contains(t, output, `"extends": "code"`)
|
||||
})
|
||||
|
||||
t.Run("includes non-empty arrays", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Network.AllowedDomains = []string{"example.com"}
|
||||
cfg.Filesystem.DenyRead = []string{".env"}
|
||||
cfg.Command.Deny = []string{"sudo"}
|
||||
|
||||
data, err := MarshalConfigJSON(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := string(data)
|
||||
assert.Contains(t, output, `"example.com"`)
|
||||
assert.Contains(t, output, `".env"`)
|
||||
assert.Contains(t, output, `"sudo"`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatConfigWithComment(t *testing.T) {
|
||||
t.Run("adds comment when extends is set", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Extends = "code"
|
||||
cfg.Command.Allow = []string{"npm install"}
|
||||
|
||||
output, err := FormatConfigWithComment(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, output, `// This config extends "code".`)
|
||||
assert.Contains(t, output, `// Network, filesystem, and command rules from "code" are inherited.`)
|
||||
assert.Contains(t, output, `"npm install"`)
|
||||
})
|
||||
|
||||
t.Run("no comment when extends is empty", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Command.Allow = []string{"npm install"}
|
||||
|
||||
output, err := FormatConfigWithComment(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, output, "//")
|
||||
assert.Contains(t, output, `"npm install"`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsGlobalToolRule(t *testing.T) {
|
||||
tests := []struct {
|
||||
rule string
|
||||
expected bool
|
||||
}{
|
||||
{"Read", true},
|
||||
{"Write", true},
|
||||
{"Grep", true},
|
||||
{"LS", true},
|
||||
{"Bash", true},
|
||||
{"Read(./.env)", false},
|
||||
{"Write(./dist/**)", false},
|
||||
{"Bash(npm install)", false},
|
||||
{"Bash(curl:*)", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.rule, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, isGlobalToolRule(tt.rule))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendUnique(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slice []string
|
||||
value string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "append to empty",
|
||||
slice: []string{},
|
||||
value: "a",
|
||||
expected: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "append new value",
|
||||
slice: []string{"a", "b"},
|
||||
value: "c",
|
||||
expected: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "skip duplicate",
|
||||
slice: []string{"a", "b"},
|
||||
value: "a",
|
||||
expected: []string{"a", "b"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := appendUnique(tt.slice, tt.value)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
// Package proxy provides HTTP and SOCKS5 proxy servers with domain filtering.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
// FilterFunc determines if a connection to host:port should be allowed.
|
||||
type FilterFunc func(host string, port int) bool
|
||||
|
||||
// HTTPProxy is an HTTP/HTTPS proxy server with domain filtering.
|
||||
type HTTPProxy struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
filter FilterFunc
|
||||
debug bool
|
||||
monitor bool
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewHTTPProxy creates a new HTTP proxy with the given filter.
|
||||
// If monitor is true, only blocked requests are logged.
|
||||
// If debug is true, all requests and filter rules are logged.
|
||||
func NewHTTPProxy(filter FilterFunc, debug, monitor bool) *HTTPProxy {
|
||||
return &HTTPProxy{
|
||||
filter: filter,
|
||||
debug: debug,
|
||||
monitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the HTTP proxy on a random available port.
|
||||
func (p *HTTPProxy) Start() (int, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
p.listener = listener
|
||||
p.server = &http.Server{
|
||||
Handler: http.HandlerFunc(p.handleRequest),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.running = true
|
||||
p.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
p.logDebug("HTTP proxy server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
p.logDebug("HTTP proxy listening on localhost:%d", addr.Port)
|
||||
return addr.Port, nil
|
||||
}
|
||||
|
||||
// Stop stops the HTTP proxy.
|
||||
func (p *HTTPProxy) Stop() error {
|
||||
p.mu.Lock()
|
||||
p.running = false
|
||||
p.mu.Unlock()
|
||||
|
||||
if p.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return p.server.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Port returns the port the proxy is listening on.
|
||||
func (p *HTTPProxy) Port() int {
|
||||
if p.listener == nil {
|
||||
return 0
|
||||
}
|
||||
return p.listener.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
p.handleConnect(w, r)
|
||||
} else {
|
||||
p.handleHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnect handles HTTPS CONNECT requests (tunnel).
|
||||
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
host, portStr, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
portStr = "443"
|
||||
}
|
||||
|
||||
port := 443
|
||||
if portStr != "" {
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
|
||||
// Check if allowed
|
||||
if !p.filter(host, port) {
|
||||
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 403, "BLOCKED", time.Since(start))
|
||||
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 200, "ALLOWED", time.Since(start))
|
||||
|
||||
// Connect to target
|
||||
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
|
||||
if err != nil {
|
||||
p.logDebug("CONNECT dial failed: %s:%d: %v", host, port, err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() { _ = targetConn.Close() }()
|
||||
|
||||
// Hijack the connection
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn, _, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to hijack connection", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() { _ = clientConn.Close() }()
|
||||
|
||||
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Pipe data bidirectionally
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(targetConn, clientConn)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = io.Copy(clientConn, targetConn)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// handleHTTP handles regular HTTP proxy requests.
|
||||
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
targetURL, err := url.Parse(r.RequestURI)
|
||||
if err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
host := targetURL.Hostname()
|
||||
port := 80
|
||||
if targetURL.Port() != "" {
|
||||
if p, err := strconv.Atoi(targetURL.Port()); err == nil {
|
||||
port = p
|
||||
}
|
||||
} else if targetURL.Scheme == "https" {
|
||||
port = 443
|
||||
}
|
||||
|
||||
if !p.filter(host, port) {
|
||||
p.logRequest(r.Method, r.RequestURI, host, 403, "BLOCKED", time.Since(start))
|
||||
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new request and copy headers
|
||||
proxyReq, err := http.NewRequest(r.Method, r.RequestURI, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for key, values := range r.Header {
|
||||
for _, value := range values {
|
||||
proxyReq.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
proxyReq.Host = targetURL.Host
|
||||
|
||||
// Remove hop-by-hop headers
|
||||
proxyReq.Header.Del("Proxy-Connection")
|
||||
proxyReq.Header.Del("Proxy-Authorization")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
p.logRequest(r.Method, r.RequestURI, host, 502, "ERROR", time.Since(start))
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
|
||||
p.logRequest(r.Method, r.RequestURI, host, resp.StatusCode, "ALLOWED", time.Since(start))
|
||||
}
|
||||
|
||||
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
|
||||
if p.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:http] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// logRequest logs a detailed request entry.
|
||||
// In monitor mode (-m), only blocked/error requests are logged.
|
||||
// In debug mode (-d), all requests are logged.
|
||||
func (p *HTTPProxy) logRequest(method, url, host string, status int, action string, duration time.Duration) {
|
||||
isBlocked := action == "BLOCKED" || action == "ERROR"
|
||||
|
||||
if p.monitor && !p.debug && !isBlocked {
|
||||
return
|
||||
}
|
||||
|
||||
if !p.debug && !p.monitor {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
statusIcon := "✓"
|
||||
switch action {
|
||||
case "BLOCKED":
|
||||
statusIcon = "✗"
|
||||
case "ERROR":
|
||||
statusIcon = "!"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[fence:http] %s %s %-7s %d %s %s (%v)\n", timestamp, statusIcon, method, status, host, truncateURL(url, 60), duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// truncateURL shortens a URL for display.
|
||||
func truncateURL(url string, maxLen int) string {
|
||||
if len(url) <= maxLen {
|
||||
return url
|
||||
}
|
||||
return url[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// CreateDomainFilter creates a filter function from a config.
|
||||
// When debug is true, logs filter rule matches to stderr.
|
||||
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
||||
return func(host string, port int) bool {
|
||||
if cfg == nil {
|
||||
// No config = deny all
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:filter] No config, denying: %s:%d\n", host, port)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check denied domains first
|
||||
for _, denied := range cfg.Network.DeniedDomains {
|
||||
if config.MatchesDomain(host, denied) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check allowed domains
|
||||
for _, allowed := range cfg.Network.AllowedDomains {
|
||||
if config.MatchesDomain(host, allowed) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:filter] No matching rule, denying: %s:%d\n", host, port)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetHostFromRequest extracts the hostname from a request.
|
||||
func GetHostFromRequest(r *http.Request) string {
|
||||
host := r.Host
|
||||
if h := r.URL.Hostname(); h != "" {
|
||||
host = h
|
||||
}
|
||||
// Strip port
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
host = host[:idx]
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
func TestTruncateURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"short url", "https://example.com", 50, "https://example.com"},
|
||||
{"exact length", "https://example.com", 19, "https://example.com"},
|
||||
{"needs truncation", "https://example.com/very/long/path/to/resource", 30, "https://example.com/very/lo..."},
|
||||
{"empty url", "", 50, ""},
|
||||
{"very short max", "https://example.com", 10, "https:/..."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := truncateURL(tt.url, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateURL(%q, %d) = %q, want %q", tt.url, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostFromRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
urlStr string
|
||||
wantHost string
|
||||
}{
|
||||
{
|
||||
name: "host header only",
|
||||
host: "example.com",
|
||||
urlStr: "/path",
|
||||
wantHost: "example.com",
|
||||
},
|
||||
{
|
||||
name: "host header with port",
|
||||
host: "example.com:8080",
|
||||
urlStr: "/path",
|
||||
wantHost: "example.com",
|
||||
},
|
||||
{
|
||||
name: "full URL overrides host",
|
||||
host: "other.com",
|
||||
urlStr: "http://example.com/path",
|
||||
wantHost: "example.com",
|
||||
},
|
||||
{
|
||||
name: "url with port",
|
||||
host: "other.com",
|
||||
urlStr: "http://example.com:9000/path",
|
||||
wantHost: "example.com",
|
||||
},
|
||||
{
|
||||
name: "ipv6 host",
|
||||
host: "[::1]:8080",
|
||||
urlStr: "/path",
|
||||
wantHost: "[::1]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsedURL, _ := url.Parse(tt.urlStr)
|
||||
req := &http.Request{
|
||||
Host: tt.host,
|
||||
URL: parsedURL,
|
||||
}
|
||||
|
||||
got := GetHostFromRequest(req)
|
||||
if got != tt.wantHost {
|
||||
t.Errorf("GetHostFromRequest() = %q, want %q", got, tt.wantHost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomainFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
host string
|
||||
port int
|
||||
allowed bool
|
||||
}{
|
||||
{
|
||||
name: "nil config denies all",
|
||||
cfg: nil,
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "allowed domain",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
},
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "denied domain takes precedence",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
DeniedDomains: []string{"example.com"},
|
||||
},
|
||||
},
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard allowed",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*.example.com"},
|
||||
},
|
||||
},
|
||||
host: "api.example.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard denied",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*.example.com"},
|
||||
DeniedDomains: []string{"*.blocked.example.com"},
|
||||
},
|
||||
},
|
||||
host: "api.blocked.example.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "unmatched domain denied",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
},
|
||||
host: "other.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "empty allowed list denies all",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
},
|
||||
},
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "star wildcard allows all",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
},
|
||||
},
|
||||
host: "any-domain.example.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "star wildcard with deny list",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
DeniedDomains: []string{"blocked.com"},
|
||||
},
|
||||
},
|
||||
host: "blocked.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "star wildcard allows non-denied",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
DeniedDomains: []string{"blocked.com"},
|
||||
},
|
||||
},
|
||||
host: "allowed.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter := CreateDomainFilter(tt.cfg, false)
|
||||
got := filter(tt.host, tt.port)
|
||||
if got != tt.allowed {
|
||||
t.Errorf("CreateDomainFilter() filter(%q, %d) = %v, want %v", tt.host, tt.port, got, tt.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomainFilterCaseInsensitive(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"Example.COM"},
|
||||
},
|
||||
}
|
||||
|
||||
filter := CreateDomainFilter(cfg, false)
|
||||
|
||||
tests := []struct {
|
||||
host string
|
||||
allowed bool
|
||||
}{
|
||||
{"example.com", true},
|
||||
{"EXAMPLE.COM", true},
|
||||
{"Example.Com", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.host, func(t *testing.T) {
|
||||
got := filter(tt.host, 443)
|
||||
if got != tt.allowed {
|
||||
t.Errorf("filter(%q) = %v, want %v", tt.host, got, tt.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHTTPProxy(t *testing.T) {
|
||||
filter := func(host string, port int) bool { return true }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
debug bool
|
||||
monitor bool
|
||||
}{
|
||||
{"default", false, false},
|
||||
{"debug mode", true, false},
|
||||
{"monitor mode", false, true},
|
||||
{"both modes", true, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
proxy := NewHTTPProxy(filter, tt.debug, tt.monitor)
|
||||
if proxy == nil {
|
||||
t.Fatal("NewHTTPProxy() returned nil")
|
||||
}
|
||||
if proxy.debug != tt.debug {
|
||||
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
|
||||
}
|
||||
if proxy.monitor != tt.monitor {
|
||||
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProxyStartStop(t *testing.T) {
|
||||
filter := func(host string, port int) bool { return true }
|
||||
proxy := NewHTTPProxy(filter, false, false)
|
||||
|
||||
port, err := proxy.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
if port <= 0 {
|
||||
t.Errorf("Start() returned invalid port: %d", port)
|
||||
}
|
||||
|
||||
if proxy.Port() != port {
|
||||
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
|
||||
}
|
||||
|
||||
if err := proxy.Stop(); err != nil {
|
||||
t.Errorf("Stop() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProxyPortBeforeStart(t *testing.T) {
|
||||
filter := func(host string, port int) bool { return true }
|
||||
proxy := NewHTTPProxy(filter, false, false)
|
||||
|
||||
if proxy.Port() != 0 {
|
||||
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/things-go/go-socks5"
|
||||
)
|
||||
|
||||
// SOCKSProxy is a SOCKS5 proxy server with domain filtering.
|
||||
type SOCKSProxy struct {
|
||||
server *socks5.Server
|
||||
listener net.Listener
|
||||
filter FilterFunc
|
||||
debug bool
|
||||
monitor bool
|
||||
port int
|
||||
}
|
||||
|
||||
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
|
||||
// If monitor is true, only blocked connections are logged.
|
||||
// If debug is true, all connections are logged.
|
||||
func NewSOCKSProxy(filter FilterFunc, debug, monitor bool) *SOCKSProxy {
|
||||
return &SOCKSProxy{
|
||||
filter: filter,
|
||||
debug: debug,
|
||||
monitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
// fenceRuleSet implements socks5.RuleSet for domain filtering.
|
||||
type fenceRuleSet struct {
|
||||
filter FilterFunc
|
||||
debug bool
|
||||
monitor bool
|
||||
}
|
||||
|
||||
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
|
||||
host := req.DestAddr.FQDN
|
||||
if host == "" {
|
||||
host = req.DestAddr.IP.String()
|
||||
}
|
||||
port := req.DestAddr.Port
|
||||
|
||||
allowed := r.filter(host, port)
|
||||
|
||||
shouldLog := r.debug || (r.monitor && !allowed)
|
||||
if shouldLog {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
if allowed {
|
||||
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✓ CONNECT %s:%d ALLOWED\n", timestamp, host, port)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✗ CONNECT %s:%d BLOCKED\n", timestamp, host, port)
|
||||
}
|
||||
}
|
||||
return ctx, allowed
|
||||
}
|
||||
|
||||
// Start starts the SOCKS5 proxy on a random available port.
|
||||
func (p *SOCKSProxy) Start() (int, error) {
|
||||
// Create listener first to get a random port
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
p.listener = listener
|
||||
p.port = listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
server := socks5.NewServer(
|
||||
socks5.WithRule(&fenceRuleSet{
|
||||
filter: p.filter,
|
||||
debug: p.debug,
|
||||
monitor: p.monitor,
|
||||
}),
|
||||
)
|
||||
p.server = server
|
||||
|
||||
go func() {
|
||||
if err := p.server.Serve(p.listener); err != nil {
|
||||
if p.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:socks] Server error: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if p.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
|
||||
}
|
||||
return p.port, nil
|
||||
}
|
||||
|
||||
// Stop stops the SOCKS5 proxy.
|
||||
func (p *SOCKSProxy) Stop() error {
|
||||
if p.listener != nil {
|
||||
return p.listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Port returns the port the proxy is listening on.
|
||||
func (p *SOCKSProxy) Port() int {
|
||||
return p.port
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/things-go/go-socks5"
|
||||
"github.com/things-go/go-socks5/statute"
|
||||
)
|
||||
|
||||
func TestFenceRuleSetAllow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fqdn string
|
||||
ip net.IP
|
||||
port int
|
||||
allowed bool
|
||||
}{
|
||||
{
|
||||
name: "allow by FQDN",
|
||||
fqdn: "allowed.com",
|
||||
port: 443,
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
name: "deny by FQDN",
|
||||
fqdn: "blocked.com",
|
||||
port: 443,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "fallback to IP when FQDN empty",
|
||||
fqdn: "",
|
||||
ip: net.ParseIP("1.2.3.4"),
|
||||
port: 80,
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
name: "allow with IP fallback",
|
||||
fqdn: "",
|
||||
ip: net.ParseIP("127.0.0.1"),
|
||||
port: 8080,
|
||||
allowed: true,
|
||||
},
|
||||
}
|
||||
|
||||
filter := func(host string, port int) bool {
|
||||
return host == "allowed.com" || host == "127.0.0.1"
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rs := &fenceRuleSet{filter: filter, debug: false, monitor: false}
|
||||
req := &socks5.Request{
|
||||
DestAddr: &statute.AddrSpec{
|
||||
FQDN: tt.fqdn,
|
||||
IP: tt.ip,
|
||||
Port: tt.port,
|
||||
},
|
||||
}
|
||||
|
||||
_, allowed := rs.Allow(context.Background(), req)
|
||||
if allowed != tt.allowed {
|
||||
t.Errorf("Allow() = %v, want %v", allowed, tt.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSOCKSProxy(t *testing.T) {
|
||||
filter := func(host string, port int) bool { return true }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
debug bool
|
||||
monitor bool
|
||||
}{
|
||||
{"default", false, false},
|
||||
{"debug mode", true, false},
|
||||
{"monitor mode", false, true},
|
||||
{"both modes", true, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
proxy := NewSOCKSProxy(filter, tt.debug, tt.monitor)
|
||||
if proxy == nil {
|
||||
t.Fatal("NewSOCKSProxy() returned nil")
|
||||
}
|
||||
if proxy.debug != tt.debug {
|
||||
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
|
||||
}
|
||||
if proxy.monitor != tt.monitor {
|
||||
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKSProxyStartStop(t *testing.T) {
|
||||
filter := func(host string, port int) bool { return true }
|
||||
proxy := NewSOCKSProxy(filter, false, false)
|
||||
|
||||
port, err := proxy.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
if port <= 0 {
|
||||
t.Errorf("Start() returned invalid port: %d", port)
|
||||
}
|
||||
|
||||
if proxy.Port() != port {
|
||||
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
|
||||
}
|
||||
|
||||
if err := proxy.Stop(); err != nil {
|
||||
t.Errorf("Stop() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKSProxyPortBeforeStart(t *testing.T) {
|
||||
filter := func(host string, port int) bool { return true }
|
||||
proxy := NewSOCKSProxy(filter, false, false)
|
||||
|
||||
if proxy.Port() != 0 {
|
||||
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
@@ -305,16 +305,14 @@ func BenchmarkOverhead(b *testing.B) {
|
||||
|
||||
func skipBenchIfSandboxed(b *testing.B) {
|
||||
b.Helper()
|
||||
if os.Getenv("FENCE_SANDBOX") == "1" {
|
||||
b.Skip("already running inside Fence sandbox")
|
||||
if os.Getenv("GREYWALL_SANDBOX") == "1" {
|
||||
b.Skip("already running inside Greywall sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
func benchConfig(workspace string) *config.Config {
|
||||
return &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
},
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{workspace},
|
||||
},
|
||||
|
||||
0
internal/sandbox/bin/.gitkeep
Normal file
0
internal/sandbox/bin/.gitkeep
Normal file
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// CommandBlockedError is returned when a command is blocked by policy.
|
||||
|
||||
@@ -3,7 +3,7 @@ package sandbox
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
func TestCheckCommand_BasicDeny(t *testing.T) {
|
||||
|
||||
@@ -39,14 +39,14 @@ func GetDefaultWritePaths() []string {
|
||||
"/dev/tty",
|
||||
"/dev/dtracehelper",
|
||||
"/dev/autofs_nowait",
|
||||
"/tmp/fence",
|
||||
"/private/tmp/fence",
|
||||
"/tmp/greywall",
|
||||
"/private/tmp/greywall",
|
||||
}
|
||||
|
||||
if home != "" {
|
||||
paths = append(paths,
|
||||
filepath.Join(home, ".npm/_logs"),
|
||||
filepath.Join(home, ".fence/debug"),
|
||||
filepath.Join(home, ".greywall/debug"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestGetDefaultWritePaths(t *testing.T) {
|
||||
t.Error("GetDefaultWritePaths() returned empty slice")
|
||||
}
|
||||
|
||||
essentialPaths := []string{"/dev/stdout", "/dev/stderr", "/dev/null", "/tmp/fence"}
|
||||
essentialPaths := []string{"/dev/stdout", "/dev/stderr", "/dev/null", "/tmp/greywall"}
|
||||
for _, essential := range essentialPaths {
|
||||
found := slices.Contains(paths, essential)
|
||||
if !found {
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
|
||||
// skipIfLandlockNotUsable skips tests that require the Landlock wrapper.
|
||||
// The Landlock wrapper re-executes the binary with --landlock-apply, which only
|
||||
// the fence CLI understands. Test binaries (e.g., sandbox.test) don't have this
|
||||
// handler, so Landlock tests must be skipped when not running as the fence CLI.
|
||||
// the greywall CLI understands. Test binaries (e.g., sandbox.test) don't have this
|
||||
// handler, so Landlock tests must be skipped when not running as the greywall CLI.
|
||||
// TODO: consider removing tests that call this function, for now can keep them
|
||||
// as documentation.
|
||||
func skipIfLandlockNotUsable(t *testing.T) {
|
||||
@@ -28,22 +28,18 @@ func skipIfLandlockNotUsable(t *testing.T) {
|
||||
t.Skip("skipping: Landlock not available on this kernel")
|
||||
}
|
||||
exePath, _ := os.Executable()
|
||||
if !strings.Contains(filepath.Base(exePath), "fence") {
|
||||
t.Skip("skipping: Landlock wrapper requires fence CLI (test binary cannot use --landlock-apply)")
|
||||
if !strings.Contains(filepath.Base(exePath), "greywall") {
|
||||
t.Skip("skipping: Landlock wrapper requires greywall CLI (test binary cannot use --landlock-apply)")
|
||||
}
|
||||
}
|
||||
|
||||
// assertNetworkBlocked verifies that a network command was blocked.
|
||||
// It checks for either a non-zero exit code OR the proxy's blocked message.
|
||||
// With no proxy configured, --unshare-net blocks all network at the kernel level.
|
||||
func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) {
|
||||
t.Helper()
|
||||
blockedMessage := "Connection blocked by network allowlist"
|
||||
if result.Failed() {
|
||||
return // Command failed = blocked
|
||||
}
|
||||
if strings.Contains(result.Stdout, blockedMessage) || strings.Contains(result.Stderr, blockedMessage) {
|
||||
return // Proxy blocked the request
|
||||
}
|
||||
t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
|
||||
result.Stdout, result.Stderr)
|
||||
}
|
||||
@@ -55,7 +51,7 @@ func TestLinux_LandlockBlocksWriteOutsideWorkspace(t *testing.T) {
|
||||
skipIfLandlockNotUsable(t)
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
outsideFile := "/tmp/fence-test-outside-" + filepath.Base(workspace) + ".txt"
|
||||
outsideFile := "/tmp/greywall-test-outside-" + filepath.Base(workspace) + ".txt"
|
||||
defer func() { _ = os.Remove(outsideFile) }()
|
||||
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
@@ -202,24 +198,24 @@ func TestLinux_LandlockBlocksWriteSystemFiles(t *testing.T) {
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
// Attempting to write to /etc should fail
|
||||
result := runUnderSandbox(t, cfg, "touch /etc/fence-test-file", workspace)
|
||||
result := runUnderSandbox(t, cfg, "touch /etc/greywall-test-file", workspace)
|
||||
|
||||
assertBlocked(t, result)
|
||||
assertFileNotExists(t, "/etc/fence-test-file")
|
||||
assertFileNotExists(t, "/etc/greywall-test-file")
|
||||
}
|
||||
|
||||
// TestLinux_LandlockAllowsTmpFence verifies /tmp/fence is writable.
|
||||
func TestLinux_LandlockAllowsTmpFence(t *testing.T) {
|
||||
// TestLinux_LandlockAllowsTmpGreywall verifies /tmp/greywall is writable.
|
||||
func TestLinux_LandlockAllowsTmpGreywall(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
skipIfLandlockNotUsable(t)
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
// Ensure /tmp/fence exists
|
||||
_ = os.MkdirAll("/tmp/fence", 0o750)
|
||||
// Ensure /tmp/greywall exists
|
||||
_ = os.MkdirAll("/tmp/greywall", 0o750)
|
||||
|
||||
testFile := "/tmp/fence/test-file-" + filepath.Base(workspace)
|
||||
testFile := "/tmp/greywall/test-file-" + filepath.Base(workspace)
|
||||
defer func() { _ = os.Remove(testFile) }()
|
||||
|
||||
result := runUnderSandbox(t, cfg, "echo 'test' > "+testFile, workspace)
|
||||
@@ -277,7 +273,7 @@ func TestLinux_NetworkBlocksCurl(t *testing.T) {
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
// No domains allowed = all network blocked
|
||||
// No proxy = all network blocked
|
||||
|
||||
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second)
|
||||
|
||||
@@ -344,18 +340,19 @@ func TestLinux_NetworkBlocksDevTcp(t *testing.T) {
|
||||
assertBlocked(t, result)
|
||||
}
|
||||
|
||||
// TestLinux_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
|
||||
func TestLinux_ProxyAllowsAllowedDomains(t *testing.T) {
|
||||
// TestLinux_TransparentProxyRoutesThroughSocks verifies traffic routes through SOCKS5 proxy.
|
||||
// This test requires a running SOCKS5 proxy and actual network connectivity.
|
||||
func TestLinux_TransparentProxyRoutesThroughSocks(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
skipIfCommandNotFound(t, "curl")
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithNetwork("httpbin.org")
|
||||
cfg := testConfigWithProxy("socks5://localhost:1080")
|
||||
cfg.Filesystem.AllowWrite = []string{workspace}
|
||||
|
||||
// This test requires actual network - skip in CI if network is unavailable
|
||||
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
|
||||
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
|
||||
// This test requires actual network and a running SOCKS5 proxy
|
||||
if os.Getenv("GREYWALL_TEST_NETWORK") != "1" {
|
||||
t.Skip("skipping: set GREYWALL_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
|
||||
}
|
||||
|
||||
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
|
||||
@@ -458,10 +455,10 @@ func TestLinux_SymlinkEscapeBlocked(t *testing.T) {
|
||||
_ = os.Symlink("/etc", symlinkPath)
|
||||
|
||||
// Try to write through the symlink
|
||||
result := runUnderSandbox(t, cfg, "echo 'test' > "+symlinkPath+"/fence-test", workspace)
|
||||
result := runUnderSandbox(t, cfg, "echo 'test' > "+symlinkPath+"/greywall-test", workspace)
|
||||
|
||||
assertBlocked(t, result)
|
||||
assertFileNotExists(t, "/etc/fence-test")
|
||||
assertFileNotExists(t, "/etc/greywall-test")
|
||||
}
|
||||
|
||||
// TestLinux_PathTraversalBlocked verifies path traversal attacks are prevented.
|
||||
@@ -473,10 +470,10 @@ func TestLinux_PathTraversalBlocked(t *testing.T) {
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
// Try to escape using ../../../
|
||||
result := runUnderSandbox(t, cfg, "touch ../../../../tmp/fence-escape-test", workspace)
|
||||
result := runUnderSandbox(t, cfg, "touch ../../../../tmp/greywall-escape-test", workspace)
|
||||
|
||||
assertBlocked(t, result)
|
||||
assertFileNotExists(t, "/tmp/fence-escape-test")
|
||||
assertFileNotExists(t, "/tmp/greywall-escape-test")
|
||||
}
|
||||
|
||||
// TestLinux_DeviceAccessBlocked verifies device files cannot be accessed.
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestMacOS_SeatbeltBlocksWriteOutsideWorkspace(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
outsideFile := "/tmp/fence-test-outside-" + filepath.Base(workspace) + ".txt"
|
||||
outsideFile := "/tmp/greywall-test-outside-" + filepath.Base(workspace) + ".txt"
|
||||
defer func() { _ = os.Remove(outsideFile) }()
|
||||
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
@@ -138,23 +138,23 @@ func TestMacOS_SeatbeltBlocksWriteSystemFiles(t *testing.T) {
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
// Attempting to write to /etc should fail
|
||||
result := runUnderSandbox(t, cfg, "touch /etc/fence-test-file", workspace)
|
||||
result := runUnderSandbox(t, cfg, "touch /etc/greywall-test-file", workspace)
|
||||
|
||||
assertBlocked(t, result)
|
||||
assertFileNotExists(t, "/etc/fence-test-file")
|
||||
assertFileNotExists(t, "/etc/greywall-test-file")
|
||||
}
|
||||
|
||||
// TestMacOS_SeatbeltAllowsTmpFence verifies /tmp/fence is writable.
|
||||
func TestMacOS_SeatbeltAllowsTmpFence(t *testing.T) {
|
||||
// TestMacOS_SeatbeltAllowsTmpGreywall verifies /tmp/greywall is writable.
|
||||
func TestMacOS_SeatbeltAllowsTmpGreywall(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
// Ensure /tmp/fence exists
|
||||
_ = os.MkdirAll("/tmp/fence", 0o750)
|
||||
// Ensure /tmp/greywall exists
|
||||
_ = os.MkdirAll("/tmp/greywall", 0o750)
|
||||
|
||||
testFile := "/tmp/fence/test-file-" + filepath.Base(workspace)
|
||||
testFile := "/tmp/greywall/test-file-" + filepath.Base(workspace)
|
||||
defer func() { _ = os.Remove(testFile) }()
|
||||
|
||||
result := runUnderSandbox(t, cfg, "echo 'test' > "+testFile, workspace)
|
||||
@@ -211,18 +211,18 @@ func TestMacOS_NetworkBlocksNc(t *testing.T) {
|
||||
assertBlocked(t, result)
|
||||
}
|
||||
|
||||
// TestMacOS_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
|
||||
func TestMacOS_ProxyAllowsAllowedDomains(t *testing.T) {
|
||||
// TestMacOS_ProxyAllowsTrafficViaProxy verifies the proxy allows traffic via external proxy.
|
||||
func TestMacOS_ProxyAllowsTrafficViaProxy(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
skipIfCommandNotFound(t, "curl")
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithNetwork("httpbin.org")
|
||||
cfg := testConfigWithProxy("socks5://localhost:1080")
|
||||
cfg.Filesystem.AllowWrite = []string{workspace}
|
||||
|
||||
// This test requires actual network - skip in CI if network is unavailable
|
||||
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
|
||||
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
|
||||
// This test requires actual network and a running SOCKS5 proxy
|
||||
if os.Getenv("GREYWALL_TEST_NETWORK") != "1" {
|
||||
t.Skip("skipping: set GREYWALL_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
|
||||
}
|
||||
|
||||
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
|
||||
@@ -290,10 +290,10 @@ func TestMacOS_SymlinkEscapeBlocked(t *testing.T) {
|
||||
}
|
||||
|
||||
// Try to write through the symlink
|
||||
result := runUnderSandbox(t, cfg, "echo 'test' > "+symlinkPath+"/fence-test", workspace)
|
||||
result := runUnderSandbox(t, cfg, "echo 'test' > "+symlinkPath+"/greywall-test", workspace)
|
||||
|
||||
assertBlocked(t, result)
|
||||
assertFileNotExists(t, "/etc/fence-test")
|
||||
assertFileNotExists(t, "/etc/greywall-test")
|
||||
}
|
||||
|
||||
// TestMacOS_PathTraversalBlocked verifies path traversal attacks are prevented.
|
||||
@@ -303,10 +303,10 @@ func TestMacOS_PathTraversalBlocked(t *testing.T) {
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
result := runUnderSandbox(t, cfg, "touch ../../../../tmp/fence-escape-test", workspace)
|
||||
result := runUnderSandbox(t, cfg, "touch ../../../../tmp/greywall-escape-test", workspace)
|
||||
|
||||
assertBlocked(t, result)
|
||||
assertFileNotExists(t, "/tmp/fence-escape-test")
|
||||
assertFileNotExists(t, "/tmp/greywall-escape-test")
|
||||
}
|
||||
|
||||
// TestMacOS_DeviceAccessBlocked verifies device files cannot be written.
|
||||
@@ -332,7 +332,7 @@ func TestMacOS_DeviceAccessBlocked(t *testing.T) {
|
||||
// ============================================================================
|
||||
|
||||
// TestMacOS_ReadOnlyPolicy verifies that files outside the allowed write paths cannot be written.
|
||||
// Note: Fence always adds some default writable paths (/tmp/fence, /dev/null, etc.)
|
||||
// Note: Greywall always adds some default writable paths (/tmp/greywall, /dev/null, etc.)
|
||||
// so "read-only" here means "outside the workspace".
|
||||
func TestMacOS_ReadOnlyPolicy(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
@@ -353,7 +353,7 @@ func TestMacOS_ReadOnlyPolicy(t *testing.T) {
|
||||
assertAllowed(t, result)
|
||||
|
||||
// Writing outside workspace should fail
|
||||
outsidePath := "/tmp/fence-test-readonly-" + filepath.Base(workspace) + ".txt"
|
||||
outsidePath := "/tmp/greywall-test-readonly-" + filepath.Base(workspace) + ".txt"
|
||||
defer func() { _ = os.Remove(outsidePath) }()
|
||||
result = runUnderSandbox(t, cfg, "echo 'outside' > "+outsidePath, workspace)
|
||||
assertBlocked(t, result)
|
||||
@@ -373,7 +373,7 @@ func TestMacOS_WorkspaceWritePolicy(t *testing.T) {
|
||||
assertFileExists(t, filepath.Join(workspace, "test.txt"))
|
||||
|
||||
// Writing outside workspace should fail
|
||||
outsideFile := "/tmp/fence-test-outside.txt"
|
||||
outsideFile := "/tmp/greywall-test-outside.txt"
|
||||
defer func() { _ = os.Remove(outsideFile) }()
|
||||
result = runUnderSandbox(t, cfg, "echo 'test' > "+outsideFile, workspace)
|
||||
assertBlocked(t, result)
|
||||
@@ -399,7 +399,7 @@ func TestMacOS_MultipleWritableRoots(t *testing.T) {
|
||||
assertAllowed(t, result)
|
||||
|
||||
// Writing outside both should fail
|
||||
outsideFile := "/tmp/fence-test-outside-multi.txt"
|
||||
outsideFile := "/tmp/greywall-test-outside-multi.txt"
|
||||
defer func() { _ = os.Remove(outsideFile) }()
|
||||
result = runUnderSandbox(t, cfg, "echo 'test' > "+outsideFile, workspace1)
|
||||
assertBlocked(t, result)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
@@ -44,8 +44,8 @@ func (r *SandboxTestResult) Failed() bool {
|
||||
// skipIfAlreadySandboxed skips the test if running inside a sandbox.
|
||||
func skipIfAlreadySandboxed(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("FENCE_SANDBOX") == "1" {
|
||||
t.Skip("skipping: already running inside Fence sandbox")
|
||||
if os.Getenv("GREYWALL_SANDBOX") == "1" {
|
||||
t.Skip("skipping: already running inside Greywall sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,10 +125,7 @@ func assertContains(t *testing.T, haystack, needle string) {
|
||||
// testConfig creates a test configuration with sensible defaults.
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
DeniedDomains: []string{},
|
||||
},
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
DenyRead: []string{},
|
||||
AllowWrite: []string{},
|
||||
@@ -149,10 +146,10 @@ func testConfigWithWorkspace(workspacePath string) *config.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// testConfigWithNetwork creates a config that allows specific domains.
|
||||
func testConfigWithNetwork(domains ...string) *config.Config {
|
||||
// testConfigWithProxy creates a config with a proxy URL set.
|
||||
func testConfigWithProxy(proxyURL string) *config.Config {
|
||||
cfg := testConfig()
|
||||
cfg.Network.AllowedDomains = domains
|
||||
cfg.Network.ProxyURL = proxyURL
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -160,7 +157,7 @@ func testConfigWithNetwork(domains ...string) *config.Config {
|
||||
// Sandbox Execution Helpers
|
||||
// ============================================================================
|
||||
|
||||
// runUnderSandbox executes a command under the fence sandbox.
|
||||
// runUnderSandbox executes a command under the greywall sandbox.
|
||||
// This uses the sandbox Manager directly for integration testing.
|
||||
func runUnderSandbox(t *testing.T, cfg *config.Config, command string, workDir string) *SandboxTestResult {
|
||||
t.Helper()
|
||||
@@ -284,7 +281,7 @@ func executeShellCommandWithTimeout(t *testing.T, command string, workDir string
|
||||
// createTempWorkspace creates a temporary directory for testing.
|
||||
func createTempWorkspace(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "fence-test-*")
|
||||
dir, err := os.MkdirTemp("", "greywall-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp workspace: %v", err)
|
||||
}
|
||||
@@ -495,10 +492,10 @@ func TestIntegration_EnvWorks(t *testing.T) {
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
|
||||
result := runUnderSandbox(t, cfg, "env | grep FENCE", workspace)
|
||||
result := runUnderSandbox(t, cfg, "env | grep GREYWALL", workspace)
|
||||
|
||||
assertAllowed(t, result)
|
||||
assertContains(t, result.Stdout, "FENCE_SANDBOX=1")
|
||||
assertContains(t, result.Stdout, "GREYWALL_SANDBOX=1")
|
||||
}
|
||||
|
||||
func TestExecuteShellCommandBwrapError(t *testing.T) {
|
||||
|
||||
@@ -7,23 +7,99 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound).
|
||||
type LinuxBridge struct {
|
||||
HTTPSocketPath string
|
||||
SOCKSSocketPath string
|
||||
httpProcess *exec.Cmd
|
||||
socksProcess *exec.Cmd
|
||||
debug bool
|
||||
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
|
||||
type ProxyBridge struct {
|
||||
SocketPath string // Unix socket path
|
||||
ProxyHost string // Parsed from ProxyURL
|
||||
ProxyPort string // Parsed from ProxyURL
|
||||
ProxyUser string // Username from ProxyURL (if any)
|
||||
ProxyPass string // Password from ProxyURL (if any)
|
||||
HasAuth bool // Whether credentials were provided
|
||||
process *exec.Cmd
|
||||
debug bool
|
||||
}
|
||||
|
||||
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket.
|
||||
// Inside the sandbox, a socat relay converts UDP DNS queries (port 53) to the Unix socket.
|
||||
// On the host, socat forwards from the Unix socket to the actual DNS server (TCP).
|
||||
type DnsBridge struct {
|
||||
SocketPath string // Unix socket path
|
||||
DnsAddr string // Host-side DNS address (host:port)
|
||||
process *exec.Cmd
|
||||
debug bool
|
||||
}
|
||||
|
||||
// NewDnsBridge creates a Unix socket bridge to a host-side DNS server.
|
||||
func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
|
||||
if _, err := exec.LookPath("socat"); err != nil {
|
||||
return nil, fmt.Errorf("socat is required for DNS bridge: %w", err)
|
||||
}
|
||||
|
||||
id := make([]byte, 8)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
|
||||
}
|
||||
socketID := hex.EncodeToString(id)
|
||||
|
||||
tmpDir := os.TempDir()
|
||||
socketPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-dns-%s.sock", socketID))
|
||||
|
||||
bridge := &DnsBridge{
|
||||
SocketPath: socketPath,
|
||||
DnsAddr: dnsAddr,
|
||||
debug: debug,
|
||||
}
|
||||
|
||||
// Start bridge: Unix socket -> DNS server TCP
|
||||
socatArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
|
||||
fmt.Sprintf("TCP:%s", dnsAddr),
|
||||
}
|
||||
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Starting DNS bridge: socat %s\n", strings.Join(socatArgs, " "))
|
||||
}
|
||||
if err := bridge.process.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start DNS bridge: %w", err)
|
||||
}
|
||||
|
||||
// Wait for socket to be created
|
||||
for range 50 {
|
||||
if fileExists(socketPath) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS bridge ready (%s -> %s)\n", socketPath, dnsAddr)
|
||||
}
|
||||
return bridge, nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
bridge.Cleanup()
|
||||
return nil, fmt.Errorf("timeout waiting for DNS bridge socket to be created")
|
||||
}
|
||||
|
||||
// Cleanup stops the DNS bridge and removes the socket file.
|
||||
func (b *DnsBridge) Cleanup() {
|
||||
if b.process != nil && b.process.Process != nil {
|
||||
_ = b.process.Process.Kill()
|
||||
_ = b.process.Wait()
|
||||
}
|
||||
_ = os.Remove(b.SocketPath)
|
||||
|
||||
if b.debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS bridge cleaned up\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ReverseBridge holds the socat bridge processes for inbound connections.
|
||||
@@ -48,13 +124,18 @@ type LinuxSandboxOptions struct {
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
|
||||
// This allows sandboxed processes to communicate with the host's proxy (outbound).
|
||||
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
||||
// NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
|
||||
// The bridge uses socat to forward from a Unix socket to the external proxy's TCP address.
|
||||
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
|
||||
if _, err := exec.LookPath("socat"); err != nil {
|
||||
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
|
||||
}
|
||||
|
||||
u, err := parseProxyURL(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
|
||||
id := make([]byte, 8)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
|
||||
@@ -62,49 +143,40 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
|
||||
socketID := hex.EncodeToString(id)
|
||||
|
||||
tmpDir := os.TempDir()
|
||||
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID))
|
||||
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
|
||||
socketPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-proxy-%s.sock", socketID))
|
||||
|
||||
bridge := &LinuxBridge{
|
||||
HTTPSocketPath: httpSocketPath,
|
||||
SOCKSSocketPath: socksSocketPath,
|
||||
debug: debug,
|
||||
bridge := &ProxyBridge{
|
||||
SocketPath: socketPath,
|
||||
ProxyHost: u.Hostname(),
|
||||
ProxyPort: u.Port(),
|
||||
debug: debug,
|
||||
}
|
||||
|
||||
// Start HTTP bridge: Unix socket -> TCP proxy
|
||||
httpArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath),
|
||||
fmt.Sprintf("TCP:localhost:%d", httpProxyPort),
|
||||
// Capture credentials from the proxy URL (if any)
|
||||
if u.User != nil {
|
||||
bridge.HasAuth = true
|
||||
bridge.ProxyUser = u.User.Username()
|
||||
bridge.ProxyPass, _ = u.User.Password()
|
||||
}
|
||||
bridge.httpProcess = exec.Command("socat", httpArgs...) //nolint:gosec // args constructed from trusted input
|
||||
|
||||
// Start bridge: Unix socket -> external SOCKS5 proxy TCP
|
||||
socatArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
|
||||
fmt.Sprintf("TCP:%s:%s", bridge.ProxyHost, bridge.ProxyPort),
|
||||
}
|
||||
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Starting HTTP bridge: socat %s\n", strings.Join(httpArgs, " "))
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Starting proxy bridge: socat %s\n", strings.Join(socatArgs, " "))
|
||||
}
|
||||
if err := bridge.httpProcess.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err)
|
||||
if err := bridge.process.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
|
||||
}
|
||||
|
||||
// Start SOCKS bridge: Unix socket -> TCP proxy
|
||||
socksArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socksSocketPath),
|
||||
fmt.Sprintf("TCP:localhost:%d", socksProxyPort),
|
||||
}
|
||||
bridge.socksProcess = exec.Command("socat", socksArgs...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Starting SOCKS bridge: socat %s\n", strings.Join(socksArgs, " "))
|
||||
}
|
||||
if err := bridge.socksProcess.Start(); err != nil {
|
||||
bridge.Cleanup()
|
||||
return nil, fmt.Errorf("failed to start SOCKS bridge: %w", err)
|
||||
}
|
||||
|
||||
// Wait for sockets to be created, up to 5 seconds
|
||||
// Wait for socket to be created, up to 5 seconds
|
||||
for range 50 {
|
||||
httpExists := fileExists(httpSocketPath)
|
||||
socksExists := fileExists(socksSocketPath)
|
||||
if httpExists && socksExists {
|
||||
if fileExists(socketPath) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges ready (HTTP: %s, SOCKS: %s)\n", httpSocketPath, socksSocketPath)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Proxy bridge ready (%s)\n", socketPath)
|
||||
}
|
||||
return bridge, nil
|
||||
}
|
||||
@@ -112,29 +184,37 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
|
||||
}
|
||||
|
||||
bridge.Cleanup()
|
||||
return nil, fmt.Errorf("timeout waiting for bridge sockets to be created")
|
||||
return nil, fmt.Errorf("timeout waiting for proxy bridge socket to be created")
|
||||
}
|
||||
|
||||
// Cleanup stops the bridge processes and removes socket files.
|
||||
func (b *LinuxBridge) Cleanup() {
|
||||
if b.httpProcess != nil && b.httpProcess.Process != nil {
|
||||
_ = b.httpProcess.Process.Kill()
|
||||
_ = b.httpProcess.Wait()
|
||||
// Cleanup stops the bridge process and removes the socket file.
|
||||
func (b *ProxyBridge) Cleanup() {
|
||||
if b.process != nil && b.process.Process != nil {
|
||||
_ = b.process.Process.Kill()
|
||||
_ = b.process.Wait()
|
||||
}
|
||||
if b.socksProcess != nil && b.socksProcess.Process != nil {
|
||||
_ = b.socksProcess.Process.Kill()
|
||||
_ = b.socksProcess.Wait()
|
||||
}
|
||||
|
||||
// Clean up socket files
|
||||
_ = os.Remove(b.HTTPSocketPath)
|
||||
_ = os.Remove(b.SOCKSSocketPath)
|
||||
_ = os.Remove(b.SocketPath)
|
||||
|
||||
if b.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges cleaned up\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Proxy bridge cleaned up\n")
|
||||
}
|
||||
}
|
||||
|
||||
// parseProxyURL parses a SOCKS5 proxy URL and returns the parsed URL.
|
||||
func parseProxyURL(proxyURL string) (*url.URL, error) {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
|
||||
return nil, fmt.Errorf("proxy URL must use socks5:// or socks5h:// scheme, got %s", u.Scheme)
|
||||
}
|
||||
if u.Hostname() == "" || u.Port() == "" {
|
||||
return nil, fmt.Errorf("proxy URL must include hostname and port")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// NewReverseBridge creates Unix socket bridges for inbound connections.
|
||||
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
|
||||
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
@@ -159,7 +239,7 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
}
|
||||
|
||||
for _, port := range ports {
|
||||
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-rev-%d-%s.sock", port, socketID))
|
||||
socketPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-rev-%d-%s.sock", port, socketID))
|
||||
bridge.SocketPaths = append(bridge.SocketPaths, socketPath)
|
||||
|
||||
// Start reverse bridge: TCP listen on host port -> Unix socket
|
||||
@@ -171,7 +251,7 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
}
|
||||
proc := exec.Command("socat", args...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Starting reverse bridge for port %d: socat %s\n", port, strings.Join(args, " "))
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Starting reverse bridge for port %d: socat %s\n", port, strings.Join(args, " "))
|
||||
}
|
||||
if err := proc.Start(); err != nil {
|
||||
bridge.Cleanup()
|
||||
@@ -181,7 +261,7 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges ready for ports: %v\n", ports)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Reverse bridges ready for ports: %v\n", ports)
|
||||
}
|
||||
|
||||
return bridge, nil
|
||||
@@ -202,7 +282,7 @@ func (b *ReverseBridge) Cleanup() {
|
||||
}
|
||||
|
||||
if b.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges cleaned up\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Reverse bridges cleaned up\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +319,37 @@ func canMountOver(path string) bool {
|
||||
return fileExists(path)
|
||||
}
|
||||
|
||||
// sameDevice returns true if both paths reside on the same filesystem (device).
|
||||
func sameDevice(path1, path2 string) bool {
|
||||
var s1, s2 syscall.Stat_t
|
||||
if syscall.Stat(path1, &s1) != nil || syscall.Stat(path2, &s2) != nil {
|
||||
return true // err on the side of caution
|
||||
}
|
||||
return s1.Dev == s2.Dev
|
||||
}
|
||||
|
||||
// intermediaryDirs returns the chain of directories between root and targetDir,
|
||||
// from shallowest to deepest. Used to create --dir entries so bwrap can set up
|
||||
// mount points inside otherwise-empty mount-point stubs.
|
||||
//
|
||||
// Example: intermediaryDirs("/", "/run/systemd/resolve") ->
|
||||
//
|
||||
// ["/run", "/run/systemd", "/run/systemd/resolve"]
|
||||
func intermediaryDirs(root, targetDir string) []string {
|
||||
rel, err := filepath.Rel(root, targetDir)
|
||||
if err != nil {
|
||||
return []string{targetDir}
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
dirs := make([]string, 0, len(parts))
|
||||
current := root
|
||||
for _, part := range parts {
|
||||
current = filepath.Join(current, part)
|
||||
dirs = append(dirs, current)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// getMandatoryDenyPaths returns concrete paths (not globs) that must be protected.
|
||||
// This expands the glob patterns from GetMandatoryDenyPatterns into real paths.
|
||||
func getMandatoryDenyPaths(cwd string) []string {
|
||||
@@ -276,8 +387,8 @@ func getMandatoryDenyPaths(cwd string) []string {
|
||||
|
||||
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
|
||||
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, dnsBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{
|
||||
UseLandlock: true, // Enabled by default, will fall back if not available
|
||||
UseSeccomp: true, // Enabled by default
|
||||
UseEBPF: true, // Enabled by default if available
|
||||
@@ -286,7 +397,7 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
|
||||
}
|
||||
|
||||
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
|
||||
}
|
||||
@@ -301,20 +412,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
features := DetectLinuxFeatures()
|
||||
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary())
|
||||
}
|
||||
|
||||
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
|
||||
// In this mode, we skip network namespace isolation so apps that don't
|
||||
// respect HTTP_PROXY can make direct connections.
|
||||
hasWildcardAllow := false
|
||||
if cfg != nil {
|
||||
hasWildcardAllow = slices.Contains(cfg.Network.AllowedDomains, "*")
|
||||
}
|
||||
|
||||
if opts.Debug && hasWildcardAllow {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Wildcard allowedDomains detected - allowing direct network connections\n")
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Available features: %s\n", features.Summary())
|
||||
}
|
||||
|
||||
// Build bwrap args with filesystem restrictions
|
||||
@@ -324,14 +422,12 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
"--die-with-parent",
|
||||
}
|
||||
|
||||
// Only use --unshare-net if:
|
||||
// 1. The environment supports it (has CAP_NET_ADMIN)
|
||||
// 2. We're NOT in wildcard mode (need direct network access)
|
||||
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
|
||||
if features.CanUnshareNet && !hasWildcardAllow {
|
||||
// Always use --unshare-net when available (network namespace isolation)
|
||||
// Inside the namespace, tun2socks will provide transparent proxy access
|
||||
if features.CanUnshareNet {
|
||||
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
|
||||
} else if opts.Debug && !features.CanUnshareNet {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
|
||||
} else if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
|
||||
}
|
||||
|
||||
bwrapArgs = append(bwrapArgs, "--unshare-pid") // PID namespace isolation
|
||||
@@ -343,12 +439,12 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
filterPath, err := filter.GenerateBPFFilter()
|
||||
if err != nil {
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Seccomp filter generation failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Seccomp filter generation failed: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
seccompFilterPath = filterPath
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Seccomp filter enabled (blocking %d dangerous syscalls)\n", len(DangerousSyscalls))
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Seccomp filter enabled (blocking %d dangerous syscalls)\n", len(DangerousSyscalls))
|
||||
}
|
||||
// Add seccomp filter via fd 3 (will be set up via shell redirection)
|
||||
bwrapArgs = append(bwrapArgs, "--seccomp", "3")
|
||||
@@ -361,7 +457,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
// In defaultDenyRead mode, we only bind essential system paths read-only
|
||||
// and user-specified allowRead paths. Everything else is inaccessible.
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
|
||||
}
|
||||
|
||||
// Bind essential system paths read-only
|
||||
@@ -411,6 +507,59 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
// /tmp needs to be writable for many programs
|
||||
bwrapArgs = append(bwrapArgs, "--tmpfs", "/tmp")
|
||||
|
||||
// Ensure /etc/resolv.conf is readable inside the sandbox.
|
||||
// On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path
|
||||
// on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't
|
||||
// reachable after --ro-bind / / (non-recursive bind). When the target
|
||||
// is on a different filesystem, we create intermediate directories and
|
||||
// bind the real file at its original location so the symlink resolves.
|
||||
if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" {
|
||||
// Skip targets under specially-mounted dirs — a --tmpfs there would
|
||||
// overwrite the --dev-bind or --proc mounts established above.
|
||||
targetUnderSpecialMount := strings.HasPrefix(target, "/dev/") ||
|
||||
strings.HasPrefix(target, "/proc/") ||
|
||||
strings.HasPrefix(target, "/tmp/")
|
||||
// In defaultDenyRead mode, also skip if the target is under a path
|
||||
// already individually bound (e.g., /run, /sys) — a --tmpfs would
|
||||
// overwrite that explicit bind. Targets under unbound paths like
|
||||
// /mnt/wsl still need the fix.
|
||||
if defaultDenyRead {
|
||||
for _, p := range GetDefaultReadablePaths() {
|
||||
if strings.HasPrefix(target, p+"/") {
|
||||
targetUnderSpecialMount = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if fileExists(target) && !sameDevice("/", target) && !targetUnderSpecialMount {
|
||||
// Make the symlink target reachable by creating its parent dirs.
|
||||
// Walk down from / to the target's parent: skip dirs on the root
|
||||
// device (they have real content like /mnt/c, /mnt/d on WSL),
|
||||
// apply --tmpfs at the mount boundary (first dir on a different
|
||||
// device — an empty mount-point stub safe to replace), then --dir
|
||||
// for any deeper subdirectories inside the now-writable tmpfs.
|
||||
targetDir := filepath.Dir(target)
|
||||
mountBoundaryFound := false
|
||||
for _, dir := range intermediaryDirs("/", targetDir) {
|
||||
if !mountBoundaryFound {
|
||||
if !sameDevice("/", dir) {
|
||||
bwrapArgs = append(bwrapArgs, "--tmpfs", dir)
|
||||
mountBoundaryFound = true
|
||||
}
|
||||
// skip dirs still on root device
|
||||
} else {
|
||||
bwrapArgs = append(bwrapArgs, "--dir", dir)
|
||||
}
|
||||
}
|
||||
if mountBoundaryFound {
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", target, target)
|
||||
}
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Resolved /etc/resolv.conf symlink -> %s (cross-mount)\n", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writablePaths := make(map[string]bool)
|
||||
|
||||
// Add default write paths (system paths needed for operation)
|
||||
@@ -481,9 +630,13 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
// Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion.
|
||||
// GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking
|
||||
// the entire directory tree - this can hang on large directories (see issue #27).
|
||||
// The concrete paths already cover dangerous files in cwd and home directory,
|
||||
// which is sufficient protection for bwrap's --ro-bind. Landlock (applied separately
|
||||
// via the wrapper) provides additional recursive protection.
|
||||
//
|
||||
// The concrete paths cover dangerous files in cwd and home directory. Files like
|
||||
// .bashrc in subdirectories are not protected, but this may be lower-risk since shell
|
||||
// rc files in project subdirectories are uncommon and not automatically sourced.
|
||||
//
|
||||
// TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect
|
||||
// subdirectory dangerous files without full tree walks that hang on large dirs.
|
||||
mandatoryDeny := getMandatoryDenyPaths(cwd)
|
||||
|
||||
// Deduplicate
|
||||
@@ -514,12 +667,51 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
}
|
||||
}
|
||||
|
||||
// Bind the outbound Unix sockets into the sandbox (need to be writable)
|
||||
if bridge != nil {
|
||||
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
|
||||
var dnsRelayResolvConf string // temp file path for custom resolv.conf
|
||||
if proxyBridge != nil {
|
||||
bwrapArgs = append(bwrapArgs,
|
||||
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath,
|
||||
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
|
||||
"--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
|
||||
)
|
||||
if tun2socksPath != "" && features.CanUseTransparentProxy() {
|
||||
// Bind /dev/net/tun for TUN device creation inside the sandbox
|
||||
if features.HasDevNetTun {
|
||||
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev/net/tun", "/dev/net/tun")
|
||||
}
|
||||
// Preserve CAP_NET_ADMIN (TUN device + network config) and
|
||||
// CAP_NET_BIND_SERVICE (DNS relay on port 53) inside the namespace
|
||||
bwrapArgs = append(bwrapArgs, "--cap-add", "CAP_NET_ADMIN")
|
||||
bwrapArgs = append(bwrapArgs, "--cap-add", "CAP_NET_BIND_SERVICE")
|
||||
// Bind the tun2socks binary into the sandbox (read-only)
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", tun2socksPath, "/tmp/greywall-tun2socks")
|
||||
}
|
||||
|
||||
// Bind DNS bridge socket if available
|
||||
if dnsBridge != nil {
|
||||
bwrapArgs = append(bwrapArgs,
|
||||
"--bind", dnsBridge.SocketPath, dnsBridge.SocketPath,
|
||||
)
|
||||
}
|
||||
|
||||
// Override /etc/resolv.conf to point DNS at our local relay (port 53).
|
||||
// Inside the sandbox, a socat relay on UDP :53 converts queries to the
|
||||
// DNS bridge (Unix socket -> host DNS server) or to TCP through the tunnel.
|
||||
if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) {
|
||||
tmpResolv, err := os.CreateTemp("", "greywall-resolv-*.conf")
|
||||
if err == nil {
|
||||
_, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n")
|
||||
tmpResolv.Close()
|
||||
dnsRelayResolvConf = tmpResolv.Name()
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
|
||||
if opts.Debug {
|
||||
if dnsBridge != nil {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (TCP relay through tunnel)\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind reverse socket directory if needed (sockets created inside sandbox)
|
||||
@@ -529,51 +721,100 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
bwrapArgs = append(bwrapArgs, "--bind", tmpDir, tmpDir)
|
||||
}
|
||||
|
||||
// Get fence executable path for Landlock wrapper
|
||||
fenceExePath, _ := os.Executable()
|
||||
// Get greywall executable path for Landlock wrapper
|
||||
greywallExePath, _ := os.Executable()
|
||||
// Skip Landlock wrapper if executable is in /tmp (test binaries are built there)
|
||||
// The wrapper won't work because --tmpfs /tmp hides the test binary
|
||||
executableInTmp := strings.HasPrefix(fenceExePath, "/tmp/")
|
||||
// Skip Landlock wrapper if fence is being used as a library (executable is not fence)
|
||||
// The wrapper re-executes the binary with --landlock-apply, which only fence understands
|
||||
executableIsFence := strings.Contains(filepath.Base(fenceExePath), "fence")
|
||||
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && fenceExePath != "" && !executableInTmp && executableIsFence
|
||||
executableInTmp := strings.HasPrefix(greywallExePath, "/tmp/")
|
||||
// Skip Landlock wrapper if greywall is being used as a library (executable is not greywall)
|
||||
// The wrapper re-executes the binary with --landlock-apply, which only greywall understands
|
||||
executableIsGreywall := strings.Contains(filepath.Base(greywallExePath), "greywall")
|
||||
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && greywallExePath != "" && !executableInTmp && executableIsGreywall
|
||||
|
||||
if opts.Debug && executableInTmp {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping Landlock wrapper (executable in /tmp, likely a test)\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping Landlock wrapper (executable in /tmp, likely a test)\n")
|
||||
}
|
||||
if opts.Debug && !executableIsFence {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping Landlock wrapper (running as library, not fence CLI)\n")
|
||||
if opts.Debug && !executableIsGreywall {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping Landlock wrapper (running as library, not greywall CLI)\n")
|
||||
}
|
||||
|
||||
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
|
||||
|
||||
// Build the inner command that sets up socat listeners and runs the user command
|
||||
// Build the inner command that sets up tun2socks and runs the user command
|
||||
var innerScript strings.Builder
|
||||
|
||||
if bridge != nil {
|
||||
// Set up outbound socat listeners inside the sandbox
|
||||
innerScript.WriteString("export GREYWALL_SANDBOX=1\n")
|
||||
|
||||
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
|
||||
// Build the tun2socks proxy URL with credentials if available
|
||||
// Many SOCKS5 proxies require the username/password auth flow even
|
||||
// without real credentials (e.g., gost always selects method 0x02).
|
||||
// Including userinfo ensures tun2socks offers both auth methods.
|
||||
tun2socksProxyURL := "socks5://127.0.0.1:${PROXY_PORT}"
|
||||
if proxyBridge.HasAuth {
|
||||
userinfo := url.UserPassword(proxyBridge.ProxyUser, proxyBridge.ProxyPass)
|
||||
tun2socksProxyURL = fmt.Sprintf("socks5://%s@127.0.0.1:${PROXY_PORT}", userinfo.String())
|
||||
}
|
||||
|
||||
// Set up transparent proxy via TUN device + tun2socks
|
||||
innerScript.WriteString(fmt.Sprintf(`
|
||||
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy)
|
||||
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
HTTP_PID=$!
|
||||
# Bring up loopback interface (needed for socat to bind on 127.0.0.1)
|
||||
ip link set lo up
|
||||
|
||||
# Start SOCKS proxy listener (port 1080 -> Unix socket -> host SOCKS proxy)
|
||||
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
SOCKS_PID=$!
|
||||
# Set up TUN device for transparent proxying
|
||||
ip tuntap add dev tun0 mode tun
|
||||
ip addr add 198.18.0.1/15 dev tun0
|
||||
ip link set dev tun0 up
|
||||
ip route add default via 198.18.0.1 dev tun0
|
||||
|
||||
# Set proxy environment variables
|
||||
export HTTP_PROXY=http://127.0.0.1:3128
|
||||
export HTTPS_PROXY=http://127.0.0.1:3128
|
||||
export http_proxy=http://127.0.0.1:3128
|
||||
export https_proxy=http://127.0.0.1:3128
|
||||
export ALL_PROXY=socks5h://127.0.0.1:1080
|
||||
export all_proxy=socks5h://127.0.0.1:1080
|
||||
# Bridge: local port -> Unix socket -> host -> external SOCKS5 proxy
|
||||
PROXY_PORT=18321
|
||||
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
BRIDGE_PID=$!
|
||||
|
||||
# Start tun2socks (transparent proxy via gvisor netstack)
|
||||
/tmp/greywall-tun2socks -device tun0 -proxy %s >/dev/null 2>&1 &
|
||||
TUN2SOCKS_PID=$!
|
||||
|
||||
`, proxyBridge.SocketPath, tun2socksProxyURL))
|
||||
|
||||
// DNS relay: convert UDP DNS queries on port 53 so apps can resolve names.
|
||||
if dnsBridge != nil {
|
||||
// Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server
|
||||
innerScript.WriteString(fmt.Sprintf(`# DNS relay: UDP queries -> Unix socket -> host DNS server (%s)
|
||||
socat UDP4-RECVFROM:53,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
DNS_RELAY_PID=$!
|
||||
|
||||
`, dnsBridge.DnsAddr, dnsBridge.SocketPath))
|
||||
} else {
|
||||
// Fallback: UDP :53 -> TCP to public DNS through the tunnel
|
||||
innerScript.WriteString(`# DNS relay: UDP queries -> TCP 1.1.1.1:53 (through tun2socks tunnel)
|
||||
socat UDP4-RECVFROM:53,fork,reuseaddr TCP:1.1.1.1:53 >/dev/null 2>&1 &
|
||||
DNS_RELAY_PID=$!
|
||||
|
||||
`)
|
||||
}
|
||||
} else if proxyBridge != nil {
|
||||
// Fallback: no TUN support, use env-var-based proxying
|
||||
innerScript.WriteString(fmt.Sprintf(`
|
||||
# Bring up loopback interface (needed for socat to bind on 127.0.0.1)
|
||||
ip link set lo up 2>/dev/null
|
||||
|
||||
# Set up SOCKS5 bridge (no TUN available, env-var-based proxying)
|
||||
PROXY_PORT=18321
|
||||
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
BRIDGE_PID=$!
|
||||
|
||||
export ALL_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export all_proxy=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export HTTP_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export HTTPS_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export http_proxy=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export https_proxy=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export NO_PROXY=localhost,127.0.0.1
|
||||
export no_proxy=localhost,127.0.0.1
|
||||
export FENCE_SANDBOX=1
|
||||
|
||||
`, bridge.HTTPSocketPath, bridge.SOCKSSocketPath))
|
||||
`, proxyBridge.SocketPath))
|
||||
}
|
||||
|
||||
// Set up reverse (inbound) socat listeners inside the sandbox
|
||||
@@ -599,8 +840,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Small delay to ensure socat listeners are ready
|
||||
sleep 0.1
|
||||
# Small delay to ensure services are ready
|
||||
sleep 0.3
|
||||
|
||||
# Run the user command
|
||||
`)
|
||||
@@ -612,13 +853,13 @@ sleep 0.1
|
||||
if cfg != nil {
|
||||
configJSON, err := json.Marshal(cfg)
|
||||
if err == nil {
|
||||
innerScript.WriteString(fmt.Sprintf("export FENCE_CONFIG_JSON=%s\n", ShellQuoteSingle(string(configJSON))))
|
||||
innerScript.WriteString(fmt.Sprintf("export GREYWALL_CONFIG_JSON=%s\n", ShellQuoteSingle(string(configJSON))))
|
||||
}
|
||||
}
|
||||
|
||||
// Build wrapper command with proper quoting
|
||||
// Use bash -c to preserve shell semantics (e.g., "echo hi && ls")
|
||||
wrapperArgs := []string{fenceExePath, "--landlock-apply"}
|
||||
wrapperArgs := []string{greywallExePath, "--landlock-apply"}
|
||||
if opts.Debug {
|
||||
wrapperArgs = append(wrapperArgs, "--debug")
|
||||
}
|
||||
@@ -640,6 +881,11 @@ sleep 0.1
|
||||
} else {
|
||||
featureList = append(featureList, "bwrap(pid,fs)")
|
||||
}
|
||||
if proxyBridge != nil && features.CanUseTransparentProxy() {
|
||||
featureList = append(featureList, "tun2socks(transparent)")
|
||||
} else if proxyBridge != nil {
|
||||
featureList = append(featureList, "proxy(env-vars)")
|
||||
}
|
||||
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
|
||||
featureList = append(featureList, "seccomp")
|
||||
}
|
||||
@@ -651,7 +897,7 @@ sleep 0.1
|
||||
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
|
||||
featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
|
||||
}
|
||||
|
||||
// Build the final command
|
||||
@@ -680,7 +926,7 @@ func StartLinuxMonitor(pid int, opts LinuxSandboxOptions) (*LinuxMonitors, error
|
||||
// or SECCOMP_RET_KILL (logs but kills process) or SECCOMP_RET_USER_NOTIF (complex).
|
||||
// For now, we rely on the eBPF monitor to detect syscall failures.
|
||||
if opts.Debug && opts.Monitor && features.SeccompLogLevel >= 1 {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Note: seccomp violations are blocked but not logged (SECCOMP_RET_ERRNO is silent)\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Note: seccomp violations are blocked but not logged (SECCOMP_RET_ERRNO is silent)\n")
|
||||
}
|
||||
|
||||
// Start eBPF monitor if available and requested
|
||||
@@ -689,17 +935,17 @@ func StartLinuxMonitor(pid int, opts LinuxSandboxOptions) (*LinuxMonitors, error
|
||||
ebpfMon := NewEBPFMonitor(pid, opts.Debug)
|
||||
if err := ebpfMon.Start(); err != nil {
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Failed to start eBPF monitor: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] Failed to start eBPF monitor: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
monitors.EBPFMonitor = ebpfMon
|
||||
if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] eBPF monitor started for PID %d\n", pid)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] eBPF monitor started for PID %d\n", pid)
|
||||
}
|
||||
}
|
||||
} else if opts.Monitor && opts.Debug {
|
||||
if !features.HasEBPF {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] eBPF monitoring not available (need CAP_BPF or root)\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:linux] eBPF monitoring not available (need CAP_BPF or root)\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,6 +975,9 @@ func PrintLinuxFeatures() {
|
||||
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
|
||||
fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI)
|
||||
fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot)
|
||||
fmt.Printf(" ip (iproute2): %v\n", features.HasIpCommand)
|
||||
fmt.Printf(" /dev/net/tun: %v\n", features.HasDevNetTun)
|
||||
fmt.Printf(" tun2socks: %v (embedded)\n", features.HasTun2Socks)
|
||||
|
||||
fmt.Printf("\nFeature Status:\n")
|
||||
if features.MinimumViable() {
|
||||
@@ -752,6 +1001,12 @@ func PrintLinuxFeatures() {
|
||||
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n")
|
||||
}
|
||||
|
||||
if features.CanUseTransparentProxy() {
|
||||
fmt.Printf(" ✓ Transparent proxy available (tun2socks + TUN device)\n")
|
||||
} else {
|
||||
fmt.Printf(" ○ Transparent proxy not available (needs ip, /dev/net/tun, network namespace)\n")
|
||||
}
|
||||
|
||||
if features.CanUseLandlock() {
|
||||
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
|
||||
} else {
|
||||
|
||||
@@ -39,7 +39,7 @@ func (m *EBPFMonitor) Start() error {
|
||||
features := DetectLinuxFeatures()
|
||||
if !features.HasEBPF {
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf] eBPF monitoring not available (need CAP_BPF or root)\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf] eBPF monitoring not available (need CAP_BPF or root)\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -51,14 +51,14 @@ func (m *EBPFMonitor) Start() error {
|
||||
// Try multiple eBPF tracing approaches
|
||||
if err := m.tryBpftrace(ctx); err != nil {
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf] bpftrace not available: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf] bpftrace not available: %v\n", err)
|
||||
}
|
||||
// Fall back to other methods
|
||||
go m.traceWithPerfEvents()
|
||||
}
|
||||
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf] Started eBPF monitoring for PID %d\n", m.pid)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf] Started eBPF monitoring for PID %d\n", m.pid)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -101,7 +101,7 @@ func (m *EBPFMonitor) tryBpftrace(ctx context.Context) error {
|
||||
script := m.generateBpftraceScript()
|
||||
|
||||
// Write script to temp file
|
||||
tmpFile, err := os.CreateTemp("", "fence-ebpf-*.bt")
|
||||
tmpFile, err := os.CreateTemp("", "greywall-ebpf-*.bt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (m *EBPFMonitor) tryBpftrace(ctx context.Context) error {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf:trace] %s\n", line)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf:trace] %s\n", line)
|
||||
}
|
||||
if violation := m.parseBpftraceOutput(line); violation != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", violation)
|
||||
@@ -150,7 +150,7 @@ func (m *EBPFMonitor) tryBpftrace(ctx context.Context) error {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf:err] %s\n", line)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf:err] %s\n", line)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (m *EBPFMonitor) generateBpftraceScript() string {
|
||||
script := fmt.Sprintf(`
|
||||
BEGIN
|
||||
{
|
||||
printf("fence:ebpf monitoring started for sandbox PID %%d (filtering pid >= %%d)\n", %d, %d);
|
||||
printf("greywall:ebpf monitoring started for sandbox PID %%d (filtering pid >= %%d)\n", %d, %d);
|
||||
}
|
||||
|
||||
// Monitor filesystem errors (EPERM=-1, EACCES=-13, EROFS=-30)
|
||||
@@ -227,7 +227,7 @@ func (m *EBPFMonitor) parseBpftraceOutput(line string) string {
|
||||
errorName := getErrnoName(ret)
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
|
||||
return fmt.Sprintf("[fence:ebpf] %s ✗ %s: %s (%s, pid=%d)",
|
||||
return fmt.Sprintf("[greywall:ebpf] %s ✗ %s: %s (%s, pid=%d)",
|
||||
timestamp, syscall, errorName, comm, pid)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func (m *EBPFMonitor) traceWithPerfEvents() {
|
||||
tracePipe := "/sys/kernel/debug/tracing/trace_pipe"
|
||||
if _, err := os.Stat(tracePipe); err != nil {
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf] trace_pipe not available\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf] trace_pipe not available\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func (m *EBPFMonitor) traceWithPerfEvents() {
|
||||
f, err := os.Open(tracePipe)
|
||||
if err != nil {
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:ebpf] Failed to open trace_pipe: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:ebpf] Failed to open trace_pipe: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -317,10 +317,10 @@ func (v *ViolationEvent) FormatViolation() string {
|
||||
errName := getErrnoName(-v.Errno)
|
||||
|
||||
if v.Path != "" {
|
||||
return fmt.Sprintf("[fence:ebpf] %s ✗ %s: %s (%s, %s:%d)",
|
||||
return fmt.Sprintf("[greywall:ebpf] %s ✗ %s: %s (%s, %s:%d)",
|
||||
timestamp, v.Operation, v.Path, errName, v.Comm, v.PID)
|
||||
}
|
||||
return fmt.Sprintf("[fence:ebpf] %s ✗ %s: %s (%s:%d)",
|
||||
return fmt.Sprintf("[greywall:ebpf] %s ✗ %s: %s (%s:%d)",
|
||||
timestamp, v.Operation, errName, v.Comm, v.PID)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ type LinuxFeatures struct {
|
||||
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
|
||||
CanUnshareNet bool
|
||||
|
||||
// Transparent proxy support
|
||||
HasIpCommand bool // ip (iproute2) available
|
||||
HasDevNetTun bool // /dev/net/tun exists
|
||||
HasTun2Socks bool // tun2socks embedded binary available
|
||||
|
||||
// Kernel version
|
||||
KernelMajor int
|
||||
KernelMinor int
|
||||
@@ -74,6 +79,12 @@ func (f *LinuxFeatures) detect() {
|
||||
|
||||
// Check if we can create network namespaces
|
||||
f.detectNetworkNamespace()
|
||||
|
||||
// Check transparent proxy support
|
||||
f.HasIpCommand = commandExists("ip")
|
||||
_, err := os.Stat("/dev/net/tun")
|
||||
f.HasDevNetTun = err == nil
|
||||
f.HasTun2Socks = true // embedded binary, always available
|
||||
}
|
||||
|
||||
func (f *LinuxFeatures) parseKernelVersion() {
|
||||
@@ -255,6 +266,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
|
||||
return f.HasLandlock && f.LandlockABI >= 1
|
||||
}
|
||||
|
||||
// CanUseTransparentProxy returns true if transparent proxying via tun2socks is possible.
|
||||
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
|
||||
return f.HasIpCommand && f.HasDevNetTun && f.CanUnshareNet
|
||||
}
|
||||
|
||||
// MinimumViable returns true if the minimum required features are available.
|
||||
func (f *LinuxFeatures) MinimumViable() bool {
|
||||
return f.HasBwrap && f.HasSocat
|
||||
|
||||
@@ -15,6 +15,9 @@ type LinuxFeatures struct {
|
||||
HasCapBPF bool
|
||||
HasCapRoot bool
|
||||
CanUnshareNet bool
|
||||
HasIpCommand bool
|
||||
HasDevNetTun bool
|
||||
HasTun2Socks bool
|
||||
KernelMajor int
|
||||
KernelMinor int
|
||||
}
|
||||
@@ -39,6 +42,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUseTransparentProxy returns false on non-Linux platforms.
|
||||
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// MinimumViable returns false on non-Linux platforms.
|
||||
func (f *LinuxFeatures) MinimumViable() bool {
|
||||
return false
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
@@ -22,7 +22,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
features := DetectLinuxFeatures()
|
||||
if !features.CanUseLandlock() {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Not available (kernel %d.%d < 5.13), skipping\n",
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Not available (kernel %d.%d < 5.13), skipping\n",
|
||||
features.KernelMajor, features.KernelMinor)
|
||||
}
|
||||
return nil // Graceful fallback - Landlock not available
|
||||
@@ -31,7 +31,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
ruleset, err := NewLandlockRuleset(debug)
|
||||
if err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to create ruleset: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to create ruleset: %v\n", err)
|
||||
}
|
||||
return nil // Graceful fallback
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
|
||||
if err := ruleset.Initialize(); err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to initialize: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to initialize: %v\n", err)
|
||||
}
|
||||
return nil // Graceful fallback
|
||||
}
|
||||
@@ -66,41 +66,51 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
if err := ruleset.AllowRead(p); err != nil && debug {
|
||||
// Ignore errors for paths that don't exist
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add read path %s: %v\n", p, err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add read path %s: %v\n", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If /etc/resolv.conf is a cross-mount symlink (e.g., -> /mnt/wsl/resolv.conf
|
||||
// on WSL), Landlock needs a read rule for the resolved target's parent dir,
|
||||
// otherwise following the symlink hits EACCES.
|
||||
if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" {
|
||||
targetDir := filepath.Dir(target)
|
||||
if err := ruleset.AllowRead(targetDir); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add resolv.conf target dir %s: %v\n", targetDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Current working directory - read access (may be upgraded to write below)
|
||||
if cwd != "" {
|
||||
if err := ruleset.AllowRead(cwd); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add cwd read path: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Home directory - read access
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if err := ruleset.AllowRead(home); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add home read path: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// /tmp - allow read+write (many programs need this)
|
||||
if err := ruleset.AllowReadWrite("/tmp"); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add /tmp write path: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add /tmp write path: %v\n", err)
|
||||
}
|
||||
|
||||
// /dev needs read+write for /dev/null, /dev/zero, /dev/tty, etc.
|
||||
// Landlock doesn't support rules on device files directly, so we allow the whole /dev
|
||||
if err := ruleset.AllowReadWrite("/dev"); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add /dev write path: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add /dev write path: %v\n", err)
|
||||
}
|
||||
|
||||
// Socket paths for proxy communication
|
||||
for _, p := range socketPaths {
|
||||
dir := filepath.Dir(p)
|
||||
if err := ruleset.AllowReadWrite(dir); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add socket path %s: %v\n", dir, err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add socket path %s: %v\n", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +119,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
|
||||
for _, p := range expandedPaths {
|
||||
if err := ruleset.AllowReadWrite(p); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add write path %s: %v\n", p, err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add write path %s: %v\n", p, err)
|
||||
}
|
||||
}
|
||||
// Also add non-glob paths directly
|
||||
@@ -117,7 +127,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
if !ContainsGlobChars(p) {
|
||||
normalized := NormalizePath(p)
|
||||
if err := ruleset.AllowReadWrite(normalized); err != nil && debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add write path %s: %v\n", normalized, err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add write path %s: %v\n", normalized, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,13 +136,13 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
|
||||
// Apply the ruleset
|
||||
if err := ruleset.Apply(); err != nil {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to apply: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to apply: %v\n", err)
|
||||
}
|
||||
return nil // Graceful fallback
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Applied restrictions (ABI v%d)\n", features.LandlockABI)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Applied restrictions (ABI v%d)\n", features.LandlockABI)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -202,7 +212,7 @@ func (l *LandlockRuleset) Initialize() error {
|
||||
l.initialized = true
|
||||
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Created ruleset (ABI v%d, fd=%d)\n", l.abiVersion, l.rulesetFd)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Created ruleset (ABI v%d, fd=%d)\n", l.abiVersion, l.rulesetFd)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -308,7 +318,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
|
||||
// Check if path exists
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Skipping non-existent path: %s\n", absPath)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Skipping non-existent path: %s\n", absPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -317,7 +327,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
|
||||
fd, err := unix.Open(absPath, unix.O_PATH|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to open path %s: %v\n", absPath, err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to open path %s: %v\n", absPath, err)
|
||||
}
|
||||
return nil // Don't fail on paths we can't access
|
||||
}
|
||||
@@ -327,7 +337,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
|
||||
var stat unix.Stat_t
|
||||
if err := unix.Fstat(fd, &stat); err != nil {
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to fstat path %s: %v\n", absPath, err)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to fstat path %s: %v\n", absPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -360,7 +370,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
|
||||
|
||||
if access == 0 {
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Skipping %s: no applicable access rights\n", absPath)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Skipping %s: no applicable access rights\n", absPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -381,7 +391,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
|
||||
}
|
||||
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Added rule: %s (access=0x%x)\n", absPath, access)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Added rule: %s (access=0x%x)\n", absPath, access)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -410,7 +420,7 @@ func (l *LandlockRuleset) Apply() error {
|
||||
}
|
||||
|
||||
if l.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:landlock] Ruleset applied to process\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Ruleset applied to process\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package sandbox
|
||||
|
||||
import "github.com/Use-Tusk/fence/internal/config"
|
||||
import "gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
|
||||
// ApplyLandlockFromConfig is a no-op on non-Linux platforms.
|
||||
func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []string, debug bool) error {
|
||||
|
||||
@@ -60,12 +60,12 @@ func (s *SeccompFilter) GenerateBPFFilter() (string, error) {
|
||||
}
|
||||
|
||||
// Create a temporary directory for the filter
|
||||
tmpDir := filepath.Join(os.TempDir(), "fence-seccomp")
|
||||
tmpDir := filepath.Join(os.TempDir(), "greywall-seccomp")
|
||||
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("failed to create seccomp dir: %w", err)
|
||||
}
|
||||
|
||||
filterPath := filepath.Join(tmpDir, fmt.Sprintf("fence-seccomp-%d.bpf", os.Getpid()))
|
||||
filterPath := filepath.Join(tmpDir, fmt.Sprintf("greywall-seccomp-%d.bpf", os.Getpid()))
|
||||
|
||||
// Generate the filter using the seccomp library or raw BPF
|
||||
// For now, we'll use bwrap's built-in seccomp support via --seccomp
|
||||
@@ -77,7 +77,7 @@ func (s *SeccompFilter) GenerateBPFFilter() (string, error) {
|
||||
}
|
||||
|
||||
if s.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:seccomp] Generated BPF filter at %s\n", filterPath)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:seccomp] Generated BPF filter at %s\n", filterPath)
|
||||
}
|
||||
|
||||
return filterPath, nil
|
||||
|
||||
@@ -5,13 +5,20 @@ package sandbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// LinuxBridge is a stub for non-Linux platforms.
|
||||
type LinuxBridge struct {
|
||||
HTTPSocketPath string
|
||||
SOCKSSocketPath string
|
||||
// ProxyBridge is a stub for non-Linux platforms.
|
||||
type ProxyBridge struct {
|
||||
SocketPath string
|
||||
ProxyHost string
|
||||
ProxyPort string
|
||||
}
|
||||
|
||||
// DnsBridge is a stub for non-Linux platforms.
|
||||
type DnsBridge struct {
|
||||
SocketPath string
|
||||
DnsAddr string
|
||||
}
|
||||
|
||||
// ReverseBridge is a stub for non-Linux platforms.
|
||||
@@ -29,13 +36,21 @@ type LinuxSandboxOptions struct {
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// NewLinuxBridge returns an error on non-Linux platforms.
|
||||
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
||||
return nil, fmt.Errorf("Linux bridge not available on this platform")
|
||||
// NewProxyBridge returns an error on non-Linux platforms.
|
||||
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
|
||||
return nil, fmt.Errorf("proxy bridge not available on this platform")
|
||||
}
|
||||
|
||||
// Cleanup is a no-op on non-Linux platforms.
|
||||
func (b *LinuxBridge) Cleanup() {}
|
||||
func (b *ProxyBridge) Cleanup() {}
|
||||
|
||||
// NewDnsBridge returns an error on non-Linux platforms.
|
||||
func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
|
||||
return nil, fmt.Errorf("DNS bridge not available on this platform")
|
||||
}
|
||||
|
||||
// Cleanup is a no-op on non-Linux platforms.
|
||||
func (b *DnsBridge) Cleanup() {}
|
||||
|
||||
// NewReverseBridge returns an error on non-Linux platforms.
|
||||
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
@@ -46,12 +61,12 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
func (b *ReverseBridge) Cleanup() {}
|
||||
|
||||
// WrapCommandLinux returns an error on non-Linux platforms.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||
}
|
||||
|
||||
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
|
||||
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,158 +3,37 @@ package sandbox
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// TestLinux_WildcardAllowedDomainsSkipsUnshareNet verifies that when allowedDomains
|
||||
// contains "*", the Linux sandbox does NOT use --unshare-net, allowing direct
|
||||
// network connections for applications that don't respect HTTP_PROXY.
|
||||
func TestLinux_WildcardAllowedDomainsSkipsUnshareNet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
wantUnshareNet bool
|
||||
}{
|
||||
{
|
||||
name: "no domains - uses unshare-net",
|
||||
allowedDomains: []string{},
|
||||
wantUnshareNet: true,
|
||||
},
|
||||
{
|
||||
name: "specific domain - uses unshare-net",
|
||||
allowedDomains: []string{"api.openai.com"},
|
||||
wantUnshareNet: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard domain - skips unshare-net",
|
||||
allowedDomains: []string{"*"},
|
||||
wantUnshareNet: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard with specific domains - skips unshare-net",
|
||||
allowedDomains: []string{"api.openai.com", "*"},
|
||||
wantUnshareNet: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain pattern - uses unshare-net",
|
||||
allowedDomains: []string{"*.openai.com"},
|
||||
wantUnshareNet: true,
|
||||
// TestLinux_NoProxyBlocksNetwork verifies that when no ProxyURL is set,
|
||||
// the Linux sandbox uses --unshare-net to block all network access.
|
||||
func TestLinux_NoProxyBlocksNetwork(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: tt.allowedDomains,
|
||||
},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
// Check the wildcard detection logic directly
|
||||
hasWildcard := hasWildcardAllowedDomain(cfg)
|
||||
|
||||
if tt.wantUnshareNet && hasWildcard {
|
||||
t.Errorf("expected hasWildcard=false for domains %v, got true", tt.allowedDomains)
|
||||
}
|
||||
if !tt.wantUnshareNet && !hasWildcard {
|
||||
t.Errorf("expected hasWildcard=true for domains %v, got false", tt.allowedDomains)
|
||||
}
|
||||
})
|
||||
// With no proxy, network should be blocked
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
t.Error("expected empty ProxyURL for no-network config")
|
||||
}
|
||||
}
|
||||
|
||||
// hasWildcardAllowedDomain checks if the config contains a "*" in allowedDomains.
|
||||
// This replicates the logic used in both linux.go and macos.go.
|
||||
func hasWildcardAllowedDomain(cfg *config.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range cfg.Network.AllowedDomains {
|
||||
if d == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestWildcardDetectionLogic tests the wildcard detection helper.
|
||||
// This logic is shared between macOS and Linux sandbox implementations.
|
||||
func TestWildcardDetectionLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
expectWildcard bool
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
cfg: nil,
|
||||
expectWildcard: false,
|
||||
// TestLinux_ProxyURLSet verifies that a proxy URL is properly set in config.
|
||||
func TestLinux_ProxyURLSet(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
{
|
||||
name: "empty allowed domains",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
},
|
||||
{
|
||||
name: "specific domains only",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com", "api.openai.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
},
|
||||
{
|
||||
name: "exact star wildcard",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
},
|
||||
},
|
||||
expectWildcard: true,
|
||||
},
|
||||
{
|
||||
name: "star wildcard among others",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com", "*", "api.openai.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: true,
|
||||
},
|
||||
{
|
||||
name: "prefix wildcard is not star",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*.example.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
},
|
||||
{
|
||||
name: "star in domain name is not wildcard",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"test*domain.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasWildcardAllowedDomain(tt.cfg)
|
||||
if got != tt.expectWildcard {
|
||||
t.Errorf("hasWildcardAllowedDomain() = %v, want %v", got, tt.expectWildcard)
|
||||
}
|
||||
})
|
||||
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Errorf("expected ProxyURL socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// sessionSuffix is a unique identifier for this process session.
|
||||
@@ -29,8 +29,9 @@ func generateSessionSuffix() string {
|
||||
type MacOSSandboxParams struct {
|
||||
Command string
|
||||
NeedsNetworkRestriction bool
|
||||
HTTPProxyPort int
|
||||
SOCKSProxyPort int
|
||||
ProxyURL string // External proxy URL (for env vars)
|
||||
ProxyHost string // Proxy host (for sandbox profile network rules)
|
||||
ProxyPort string // Proxy port (for sandbox profile network rules)
|
||||
AllowUnixSockets []string
|
||||
AllowAllUnixSockets bool
|
||||
AllowLocalBinding bool
|
||||
@@ -519,18 +520,10 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
}
|
||||
}
|
||||
|
||||
if params.HTTPProxyPort > 0 {
|
||||
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
||||
(allow network-inbound (local ip "localhost:%d"))
|
||||
(allow network-outbound (remote ip "localhost:%d"))
|
||||
`, params.HTTPProxyPort, params.HTTPProxyPort, params.HTTPProxyPort))
|
||||
}
|
||||
|
||||
if params.SOCKSProxyPort > 0 {
|
||||
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
||||
(allow network-inbound (local ip "localhost:%d"))
|
||||
(allow network-outbound (remote ip "localhost:%d"))
|
||||
`, params.SOCKSProxyPort, params.SOCKSProxyPort, params.SOCKSProxyPort))
|
||||
// Allow outbound to the external proxy host:port
|
||||
if params.ProxyHost != "" && params.ProxyPort != "" {
|
||||
profile.WriteString(fmt.Sprintf(`(allow network-outbound (remote ip "%s:%s"))
|
||||
`, params.ProxyHost, params.ProxyPort))
|
||||
}
|
||||
}
|
||||
profile.WriteString("\n")
|
||||
@@ -568,15 +561,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
}
|
||||
|
||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
|
||||
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
|
||||
// In this mode, we still run the proxy for apps that respect HTTP_PROXY,
|
||||
// but allow direct connections for apps that don't (like cursor-agent, opencode).
|
||||
// deniedDomains will only be enforced for apps that use the proxy.
|
||||
hasWildcardAllow := slices.Contains(cfg.Network.AllowedDomains, "*")
|
||||
|
||||
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
|
||||
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
||||
// Build allow paths: default + configured
|
||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||
|
||||
@@ -591,20 +576,25 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
|
||||
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
||||
}
|
||||
|
||||
// If wildcard allow, don't restrict network at sandbox level (allow direct connections).
|
||||
// Otherwise, restrict to localhost/proxy only (strict mode).
|
||||
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
|
||||
|
||||
if debug && hasWildcardAllow {
|
||||
fmt.Fprintf(os.Stderr, "[fence:macos] Wildcard allowedDomains detected - allowing direct network connections\n")
|
||||
fmt.Fprintf(os.Stderr, "[fence:macos] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
|
||||
// Parse proxy URL for network rules
|
||||
var proxyHost, proxyPort string
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
|
||||
proxyHost = u.Hostname()
|
||||
proxyPort = u.Port()
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict network unless proxy is configured to an external host
|
||||
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
|
||||
needsNetworkRestriction := true
|
||||
|
||||
params := MacOSSandboxParams{
|
||||
Command: command,
|
||||
NeedsNetworkRestriction: needsNetworkRestriction,
|
||||
HTTPProxyPort: httpPort,
|
||||
SOCKSProxyPort: socksPort,
|
||||
ProxyURL: cfg.Network.ProxyURL,
|
||||
ProxyHost: proxyHost,
|
||||
ProxyPort: proxyPort,
|
||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: allowLocalBinding,
|
||||
@@ -619,10 +609,10 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
|
||||
}
|
||||
|
||||
if debug && len(exposedPorts) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[fence:macos] Enabling local binding for exposed ports: %v\n", exposedPorts)
|
||||
fmt.Fprintf(os.Stderr, "[greywall:macos] Enabling local binding for exposed ports: %v\n", exposedPorts)
|
||||
}
|
||||
if debug && allowLocalBinding && !allowLocalOutbound {
|
||||
fmt.Fprintf(os.Stderr, "[fence:macos] Blocking localhost outbound (AllowLocalOutbound=false)\n")
|
||||
fmt.Fprintf(os.Stderr, "[greywall:macos] Blocking localhost outbound (AllowLocalOutbound=false)\n")
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
@@ -637,7 +627,7 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
|
||||
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||
}
|
||||
|
||||
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
|
||||
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
|
||||
|
||||
// Build the command
|
||||
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
|
||||
|
||||
@@ -4,47 +4,37 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
)
|
||||
|
||||
// TestMacOS_WildcardAllowedDomainsRelaxesNetwork verifies that when allowedDomains
|
||||
// contains "*", the macOS sandbox profile allows direct network connections.
|
||||
func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
||||
// TestMacOS_NetworkRestrictionWithProxy verifies that when a proxy URL is set,
|
||||
// the macOS sandbox profile allows outbound to the proxy host:port.
|
||||
func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
wantNetworkRestricted bool
|
||||
wantAllowNetworkOutbound bool
|
||||
name string
|
||||
proxyURL string
|
||||
wantProxy bool
|
||||
proxyHost string
|
||||
proxyPort string
|
||||
}{
|
||||
{
|
||||
name: "no domains - network restricted",
|
||||
allowedDomains: []string{},
|
||||
wantNetworkRestricted: true,
|
||||
wantAllowNetworkOutbound: false,
|
||||
name: "no proxy - network blocked",
|
||||
proxyURL: "",
|
||||
wantProxy: false,
|
||||
},
|
||||
{
|
||||
name: "specific domain - network restricted",
|
||||
allowedDomains: []string{"api.openai.com"},
|
||||
wantNetworkRestricted: true,
|
||||
wantAllowNetworkOutbound: false,
|
||||
name: "socks5 proxy - outbound allowed to proxy",
|
||||
proxyURL: "socks5://proxy.example.com:1080",
|
||||
wantProxy: true,
|
||||
proxyHost: "proxy.example.com",
|
||||
proxyPort: "1080",
|
||||
},
|
||||
{
|
||||
name: "wildcard domain - network unrestricted",
|
||||
allowedDomains: []string{"*"},
|
||||
wantNetworkRestricted: false,
|
||||
wantAllowNetworkOutbound: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard with specific domains - network unrestricted",
|
||||
allowedDomains: []string{"api.openai.com", "*"},
|
||||
wantNetworkRestricted: false,
|
||||
wantAllowNetworkOutbound: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain pattern - network restricted",
|
||||
allowedDomains: []string{"*.openai.com"},
|
||||
wantNetworkRestricted: true,
|
||||
wantAllowNetworkOutbound: false,
|
||||
name: "socks5h proxy - outbound allowed to proxy",
|
||||
proxyURL: "socks5h://localhost:1080",
|
||||
wantProxy: true,
|
||||
proxyHost: "localhost",
|
||||
proxyPort: "1080",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,34 +42,33 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: tt.allowedDomains,
|
||||
ProxyURL: tt.proxyURL,
|
||||
},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate the sandbox profile parameters
|
||||
params := buildMacOSParamsForTest(cfg)
|
||||
|
||||
if params.NeedsNetworkRestriction != tt.wantNetworkRestricted {
|
||||
t.Errorf("NeedsNetworkRestriction = %v, want %v",
|
||||
params.NeedsNetworkRestriction, tt.wantNetworkRestricted)
|
||||
if tt.wantProxy {
|
||||
if params.ProxyHost != tt.proxyHost {
|
||||
t.Errorf("expected ProxyHost %q, got %q", tt.proxyHost, params.ProxyHost)
|
||||
}
|
||||
if params.ProxyPort != tt.proxyPort {
|
||||
t.Errorf("expected ProxyPort %q, got %q", tt.proxyPort, params.ProxyPort)
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
expectedRule := `(allow network-outbound (remote ip "` + tt.proxyHost + ":" + tt.proxyPort + `"))`
|
||||
if !strings.Contains(profile, expectedRule) {
|
||||
t.Errorf("profile should contain proxy outbound rule %q", expectedRule)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the actual profile and check its contents
|
||||
profile := GenerateSandboxProfile(params)
|
||||
|
||||
// When network is unrestricted, profile should allow network* (all network ops)
|
||||
if tt.wantAllowNetworkOutbound {
|
||||
if !strings.Contains(profile, "(allow network*)") {
|
||||
t.Errorf("expected unrestricted network profile to contain '(allow network*)', got:\n%s", profile)
|
||||
}
|
||||
} else {
|
||||
// When network is restricted, profile should NOT have blanket allow
|
||||
if strings.Contains(profile, "(allow network*)") {
|
||||
t.Errorf("expected restricted network profile to NOT contain blanket '(allow network*)'")
|
||||
}
|
||||
// Network should always be restricted (proxy or not)
|
||||
if !params.NeedsNetworkRestriction {
|
||||
t.Error("NeedsNetworkRestriction should always be true")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -88,15 +77,6 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
||||
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
|
||||
// replicating the logic in WrapCommandMacOS for testing.
|
||||
func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
||||
hasWildcardAllow := false
|
||||
for _, d := range cfg.Network.AllowedDomains {
|
||||
if d == "*" {
|
||||
hasWildcardAllow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
|
||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||
allowLocalBinding := cfg.Network.AllowLocalBinding
|
||||
allowLocalOutbound := allowLocalBinding
|
||||
@@ -104,13 +84,26 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
||||
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
||||
}
|
||||
|
||||
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
|
||||
var proxyHost, proxyPort string
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
// Simple parsing for tests
|
||||
parts := strings.SplitN(cfg.Network.ProxyURL, "://", 2)
|
||||
if len(parts) == 2 {
|
||||
hostPort := parts[1]
|
||||
colonIdx := strings.LastIndex(hostPort, ":")
|
||||
if colonIdx >= 0 {
|
||||
proxyHost = hostPort[:colonIdx]
|
||||
proxyPort = hostPort[colonIdx+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
NeedsNetworkRestriction: needsNetworkRestriction,
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
NeedsNetworkRestriction: true,
|
||||
ProxyURL: cfg.Network.ProxyURL,
|
||||
ProxyHost: proxyHost,
|
||||
ProxyPort: proxyPort,
|
||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: allowLocalBinding,
|
||||
@@ -158,8 +151,6 @@ func TestMacOS_ProfileNetworkSection(t *testing.T) {
|
||||
params := MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
NeedsNetworkRestriction: tt.restricted,
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
@@ -195,8 +186,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
defaultDenyRead: false,
|
||||
allowRead: nil,
|
||||
wantContainsBlanketAllow: true,
|
||||
wantContainsMetadataAllow: false, // No separate metadata allow needed
|
||||
wantContainsSystemAllows: false, // No need for explicit system allows
|
||||
wantContainsMetadataAllow: false,
|
||||
wantContainsSystemAllows: false,
|
||||
wantContainsUserAllowRead: false,
|
||||
},
|
||||
{
|
||||
@@ -204,8 +195,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
defaultDenyRead: true,
|
||||
allowRead: nil,
|
||||
wantContainsBlanketAllow: false,
|
||||
wantContainsMetadataAllow: true, // Should have file-read-metadata for traversal
|
||||
wantContainsSystemAllows: true, // Should have explicit system path allows
|
||||
wantContainsMetadataAllow: true,
|
||||
wantContainsSystemAllows: true,
|
||||
wantContainsUserAllowRead: false,
|
||||
},
|
||||
{
|
||||
@@ -223,35 +214,28 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
DefaultDenyRead: tt.defaultDenyRead,
|
||||
ReadAllowPaths: tt.allowRead,
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
|
||||
// Check for blanket "(allow file-read*)" without path restrictions
|
||||
// This appears at the start of read rules section in default mode
|
||||
hasBlanketAllow := strings.Contains(profile, "(allow file-read*)\n")
|
||||
if hasBlanketAllow != tt.wantContainsBlanketAllow {
|
||||
t.Errorf("blanket file-read allow = %v, want %v", hasBlanketAllow, tt.wantContainsBlanketAllow)
|
||||
}
|
||||
|
||||
// Check for file-read-metadata allow (for directory traversal in defaultDenyRead mode)
|
||||
hasMetadataAllow := strings.Contains(profile, "(allow file-read-metadata)")
|
||||
if hasMetadataAllow != tt.wantContainsMetadataAllow {
|
||||
t.Errorf("file-read-metadata allow = %v, want %v", hasMetadataAllow, tt.wantContainsMetadataAllow)
|
||||
}
|
||||
|
||||
// Check for system path allows (e.g., /usr, /bin) - should use file-read-data in strict mode
|
||||
hasSystemAllows := strings.Contains(profile, `(subpath "/usr")`) ||
|
||||
strings.Contains(profile, `(subpath "/bin")`)
|
||||
if hasSystemAllows != tt.wantContainsSystemAllows {
|
||||
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
|
||||
}
|
||||
|
||||
// Check for user-specified allowRead paths
|
||||
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
|
||||
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
||||
if !hasUserAllow {
|
||||
@@ -290,14 +274,14 @@ func TestExpandMacOSTmpPaths(t *testing.T) {
|
||||
want: []string{".", "~/.cache"},
|
||||
},
|
||||
{
|
||||
name: "mirrors /tmp/fence to /private/tmp/fence",
|
||||
input: []string{".", "/tmp/fence"},
|
||||
want: []string{".", "/tmp/fence", "/private/tmp/fence"},
|
||||
name: "mirrors /tmp/greywall to /private/tmp/greywall",
|
||||
input: []string{".", "/tmp/greywall"},
|
||||
want: []string{".", "/tmp/greywall", "/private/tmp/greywall"},
|
||||
},
|
||||
{
|
||||
name: "mirrors /private/tmp/fence to /tmp/fence",
|
||||
input: []string{".", "/private/tmp/fence"},
|
||||
want: []string{".", "/private/tmp/fence", "/tmp/fence"},
|
||||
name: "mirrors /private/tmp/greywall to /tmp/greywall",
|
||||
input: []string{".", "/private/tmp/greywall"},
|
||||
want: []string{".", "/private/tmp/greywall", "/tmp/greywall"},
|
||||
},
|
||||
{
|
||||
name: "mirrors nested subdirectory",
|
||||
@@ -306,8 +290,8 @@ func TestExpandMacOSTmpPaths(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no duplicate when mirror already present",
|
||||
input: []string{".", "/tmp/fence", "/private/tmp/fence"},
|
||||
want: []string{".", "/tmp/fence", "/private/tmp/fence"},
|
||||
input: []string{".", "/tmp/greywall", "/private/tmp/greywall"},
|
||||
want: []string{".", "/tmp/greywall", "/private/tmp/greywall"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,17 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/Use-Tusk/fence/internal/platform"
|
||||
"github.com/Use-Tusk/fence/internal/proxy"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
||||
)
|
||||
|
||||
// Manager handles sandbox initialization and command wrapping.
|
||||
type Manager struct {
|
||||
config *config.Config
|
||||
httpProxy *proxy.HTTPProxy
|
||||
socksProxy *proxy.SOCKSProxy
|
||||
linuxBridge *LinuxBridge
|
||||
proxyBridge *ProxyBridge
|
||||
dnsBridge *DnsBridge
|
||||
reverseBridge *ReverseBridge
|
||||
httpPort int
|
||||
socksPort int
|
||||
tun2socksPath string // path to extracted tun2socks binary on host
|
||||
exposedPorts []int
|
||||
debug bool
|
||||
monitor bool
|
||||
@@ -38,7 +35,7 @@ func (m *Manager) SetExposedPorts(ports []int) {
|
||||
m.exposedPorts = ports
|
||||
}
|
||||
|
||||
// Initialize sets up the sandbox infrastructure (proxies, etc.).
|
||||
// Initialize sets up the sandbox infrastructure.
|
||||
func (m *Manager) Initialize() error {
|
||||
if m.initialized {
|
||||
return nil
|
||||
@@ -48,32 +45,40 @@ func (m *Manager) Initialize() error {
|
||||
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
||||
}
|
||||
|
||||
filter := proxy.CreateDomainFilter(m.config, m.debug)
|
||||
|
||||
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug, m.monitor)
|
||||
httpPort, err := m.httpProxy.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
||||
}
|
||||
m.httpPort = httpPort
|
||||
|
||||
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug, m.monitor)
|
||||
socksPort, err := m.socksProxy.Start()
|
||||
if err != nil {
|
||||
_ = m.httpProxy.Stop()
|
||||
return fmt.Errorf("failed to start SOCKS proxy: %w", err)
|
||||
}
|
||||
m.socksPort = socksPort
|
||||
|
||||
// On Linux, set up the socat bridges
|
||||
// On Linux, set up proxy bridge and tun2socks if proxy is configured
|
||||
if platform.Detect() == platform.Linux {
|
||||
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug)
|
||||
if err != nil {
|
||||
_ = m.httpProxy.Stop()
|
||||
_ = m.socksProxy.Stop()
|
||||
return fmt.Errorf("failed to initialize Linux bridge: %w", err)
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
// Extract embedded tun2socks binary
|
||||
tun2socksPath, err := extractTun2Socks()
|
||||
if err != nil {
|
||||
m.logDebug("Failed to extract tun2socks: %v (will fall back to env-var proxying)", err)
|
||||
} else {
|
||||
m.tun2socksPath = tun2socksPath
|
||||
}
|
||||
|
||||
// Create proxy bridge (socat: Unix socket -> external SOCKS5 proxy)
|
||||
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
|
||||
if err != nil {
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
|
||||
}
|
||||
m.proxyBridge = bridge
|
||||
|
||||
// Create DNS bridge if a DNS server is configured
|
||||
if m.config.Network.DnsAddr != "" {
|
||||
dnsBridge, err := NewDnsBridge(m.config.Network.DnsAddr, m.debug)
|
||||
if err != nil {
|
||||
m.proxyBridge.Cleanup()
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
return fmt.Errorf("failed to initialize DNS bridge: %w", err)
|
||||
}
|
||||
m.dnsBridge = dnsBridge
|
||||
}
|
||||
}
|
||||
m.linuxBridge = bridge
|
||||
|
||||
// Set up reverse bridge for exposed ports (inbound connections)
|
||||
// Only needed when network namespace is available - otherwise they share the network
|
||||
@@ -81,9 +86,12 @@ func (m *Manager) Initialize() error {
|
||||
if len(m.exposedPorts) > 0 && features.CanUnshareNet {
|
||||
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
|
||||
if err != nil {
|
||||
m.linuxBridge.Cleanup()
|
||||
_ = m.httpProxy.Stop()
|
||||
_ = m.socksProxy.Stop()
|
||||
if m.proxyBridge != nil {
|
||||
m.proxyBridge.Cleanup()
|
||||
}
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
||||
}
|
||||
m.reverseBridge = reverseBridge
|
||||
@@ -93,7 +101,11 @@ func (m *Manager) Initialize() error {
|
||||
}
|
||||
|
||||
m.initialized = true
|
||||
m.logDebug("Sandbox manager initialized (HTTP proxy: %d, SOCKS proxy: %d)", m.httpPort, m.socksPort)
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
m.logDebug("Sandbox manager initialized (proxy: %s)", m.config.Network.ProxyURL)
|
||||
} else {
|
||||
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,9 +126,9 @@ func (m *Manager) WrapCommand(command string) (string, error) {
|
||||
plat := platform.Detect()
|
||||
switch plat {
|
||||
case platform.MacOS:
|
||||
return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug)
|
||||
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
|
||||
case platform.Linux:
|
||||
return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug)
|
||||
return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform: %s", plat)
|
||||
}
|
||||
@@ -127,30 +139,20 @@ func (m *Manager) Cleanup() {
|
||||
if m.reverseBridge != nil {
|
||||
m.reverseBridge.Cleanup()
|
||||
}
|
||||
if m.linuxBridge != nil {
|
||||
m.linuxBridge.Cleanup()
|
||||
if m.dnsBridge != nil {
|
||||
m.dnsBridge.Cleanup()
|
||||
}
|
||||
if m.httpProxy != nil {
|
||||
_ = m.httpProxy.Stop()
|
||||
if m.proxyBridge != nil {
|
||||
m.proxyBridge.Cleanup()
|
||||
}
|
||||
if m.socksProxy != nil {
|
||||
_ = m.socksProxy.Stop()
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
m.logDebug("Sandbox manager cleaned up")
|
||||
}
|
||||
|
||||
func (m *Manager) logDebug(format string, args ...interface{}) {
|
||||
if m.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence] "+format+"\n", args...)
|
||||
fmt.Fprintf(os.Stderr, "[greywall] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPPort returns the HTTP proxy port.
|
||||
func (m *Manager) HTTPPort() int {
|
||||
return m.httpPort
|
||||
}
|
||||
|
||||
// SOCKSPort returns the SOCKS proxy port.
|
||||
func (m *Manager) SOCKSPort() int {
|
||||
return m.socksPort
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/platform"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
||||
)
|
||||
|
||||
// LogMonitor monitors sandbox violations via macOS log stream.
|
||||
@@ -138,9 +138,9 @@ func parseViolation(line string) string {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
|
||||
if details != "" {
|
||||
return fmt.Sprintf("[fence:logstream] %s ✗ %s %s (%s:%s)", timestamp, operation, details, process, pid)
|
||||
return fmt.Sprintf("[greywall:logstream] %s ✗ %s %s (%s:%s)", timestamp, operation, details, process, pid)
|
||||
}
|
||||
return fmt.Sprintf("[fence:logstream] %s ✗ %s (%s:%s)", timestamp, operation, process, pid)
|
||||
return fmt.Sprintf("[greywall:logstream] %s ✗ %s (%s:%s)", timestamp, operation, process, pid)
|
||||
}
|
||||
|
||||
// shouldShowViolation returns true if this violation type should be displayed.
|
||||
|
||||
53
internal/sandbox/tun2socks_embed.go
Normal file
53
internal/sandbox/tun2socks_embed.go
Normal file
@@ -0,0 +1,53 @@
|
||||
//go:build linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
//go:embed bin/tun2socks-linux-*
|
||||
var tun2socksFS embed.FS
|
||||
|
||||
// extractTun2Socks writes the embedded tun2socks binary to a temp file and returns its path.
|
||||
// The caller is responsible for removing the file when done.
|
||||
func extractTun2Socks() (string, error) {
|
||||
var arch string
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
arch = "amd64"
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
default:
|
||||
return "", fmt.Errorf("tun2socks: unsupported architecture %s", runtime.GOARCH)
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("bin/tun2socks-linux-%s", arch)
|
||||
data, err := fs.ReadFile(tun2socksFS, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tun2socks: embedded binary not found for %s: %w", arch, err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "greywall-tun2socks-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tun2socks: failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("tun2socks: failed to write binary: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("tun2socks: failed to make executable: %w", err)
|
||||
}
|
||||
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
10
internal/sandbox/tun2socks_embed_stub.go
Normal file
10
internal/sandbox/tun2socks_embed_stub.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !linux
|
||||
|
||||
package sandbox
|
||||
|
||||
import "fmt"
|
||||
|
||||
// extractTun2Socks is not available on non-Linux platforms.
|
||||
func extractTun2Socks() (string, error) {
|
||||
return "", fmt.Errorf("tun2socks is only available on Linux")
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -49,13 +48,14 @@ func NormalizePath(pathPattern string) string {
|
||||
}
|
||||
|
||||
// GenerateProxyEnvVars creates environment variables for proxy configuration.
|
||||
func GenerateProxyEnvVars(httpPort, socksPort int) []string {
|
||||
// Used on macOS where transparent proxying is not available.
|
||||
func GenerateProxyEnvVars(proxyURL string) []string {
|
||||
envVars := []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"TMPDIR=/tmp/fence",
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"TMPDIR=/tmp/greywall",
|
||||
}
|
||||
|
||||
if httpPort == 0 && socksPort == 0 {
|
||||
if proxyURL == "" {
|
||||
return envVars
|
||||
}
|
||||
|
||||
@@ -75,32 +75,14 @@ func GenerateProxyEnvVars(httpPort, socksPort int) []string {
|
||||
envVars = append(envVars,
|
||||
"NO_PROXY="+noProxy,
|
||||
"no_proxy="+noProxy,
|
||||
"ALL_PROXY="+proxyURL,
|
||||
"all_proxy="+proxyURL,
|
||||
"HTTP_PROXY="+proxyURL,
|
||||
"HTTPS_PROXY="+proxyURL,
|
||||
"http_proxy="+proxyURL,
|
||||
"https_proxy="+proxyURL,
|
||||
)
|
||||
|
||||
if httpPort > 0 {
|
||||
proxyURL := "http://localhost:" + itoa(httpPort)
|
||||
envVars = append(envVars,
|
||||
"HTTP_PROXY="+proxyURL,
|
||||
"HTTPS_PROXY="+proxyURL,
|
||||
"http_proxy="+proxyURL,
|
||||
"https_proxy="+proxyURL,
|
||||
)
|
||||
}
|
||||
|
||||
if socksPort > 0 {
|
||||
socksURL := "socks5h://localhost:" + itoa(socksPort)
|
||||
envVars = append(envVars,
|
||||
"ALL_PROXY="+socksURL,
|
||||
"all_proxy="+socksURL,
|
||||
"FTP_PROXY="+socksURL,
|
||||
"ftp_proxy="+socksURL,
|
||||
)
|
||||
// Git SSH through SOCKS
|
||||
envVars = append(envVars,
|
||||
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:"+itoa(socksPort)+" %h %p'",
|
||||
)
|
||||
}
|
||||
|
||||
return envVars
|
||||
}
|
||||
|
||||
@@ -121,6 +103,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
@@ -125,19 +125,17 @@ func TestNormalizePath(t *testing.T) {
|
||||
|
||||
func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
httpPort int
|
||||
socksPort int
|
||||
wantEnvs []string
|
||||
dontWant []string
|
||||
name string
|
||||
proxyURL string
|
||||
wantEnvs []string
|
||||
dontWant []string
|
||||
}{
|
||||
{
|
||||
name: "no ports",
|
||||
httpPort: 0,
|
||||
socksPort: 0,
|
||||
name: "no proxy",
|
||||
proxyURL: "",
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"TMPDIR=/tmp/fence",
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"TMPDIR=/tmp/greywall",
|
||||
},
|
||||
dontWant: []string{
|
||||
"HTTP_PROXY=",
|
||||
@@ -146,56 +144,34 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http port only",
|
||||
httpPort: 8080,
|
||||
socksPort: 0,
|
||||
name: "socks5 proxy",
|
||||
proxyURL: "socks5://localhost:1080",
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"HTTP_PROXY=http://localhost:8080",
|
||||
"HTTPS_PROXY=http://localhost:8080",
|
||||
"http_proxy=http://localhost:8080",
|
||||
"https_proxy=http://localhost:8080",
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"ALL_PROXY=socks5://localhost:1080",
|
||||
"all_proxy=socks5://localhost:1080",
|
||||
"HTTP_PROXY=socks5://localhost:1080",
|
||||
"HTTPS_PROXY=socks5://localhost:1080",
|
||||
"http_proxy=socks5://localhost:1080",
|
||||
"https_proxy=socks5://localhost:1080",
|
||||
"NO_PROXY=",
|
||||
"no_proxy=",
|
||||
},
|
||||
dontWant: []string{
|
||||
"ALL_PROXY=",
|
||||
"all_proxy=",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "socks port only",
|
||||
httpPort: 0,
|
||||
socksPort: 1080,
|
||||
name: "socks5h proxy",
|
||||
proxyURL: "socks5h://proxy.example.com:1080",
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"ALL_PROXY=socks5h://localhost:1080",
|
||||
"all_proxy=socks5h://localhost:1080",
|
||||
"FTP_PROXY=socks5h://localhost:1080",
|
||||
"GIT_SSH_COMMAND=",
|
||||
},
|
||||
dontWant: []string{
|
||||
"HTTP_PROXY=",
|
||||
"HTTPS_PROXY=",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "both ports",
|
||||
httpPort: 8080,
|
||||
socksPort: 1080,
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"HTTP_PROXY=http://localhost:8080",
|
||||
"HTTPS_PROXY=http://localhost:8080",
|
||||
"ALL_PROXY=socks5h://localhost:1080",
|
||||
"GIT_SSH_COMMAND=",
|
||||
"GREYWALL_SANDBOX=1",
|
||||
"ALL_PROXY=socks5h://proxy.example.com:1080",
|
||||
"HTTP_PROXY=socks5h://proxy.example.com:1080",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateProxyEnvVars(tt.httpPort, tt.socksPort)
|
||||
got := GenerateProxyEnvVars(tt.proxyURL)
|
||||
|
||||
// Check expected env vars are present
|
||||
for _, want := range tt.wantEnvs {
|
||||
@@ -207,7 +183,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GenerateProxyEnvVars(%d, %d) missing %q", tt.httpPort, tt.socksPort, want)
|
||||
t.Errorf("GenerateProxyEnvVars(%q) missing %q", tt.proxyURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +191,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
for _, dontWant := range tt.dontWant {
|
||||
for _, env := range got {
|
||||
if strings.HasPrefix(env, dontWant) {
|
||||
t.Errorf("GenerateProxyEnvVars(%d, %d) should not contain %q, got %q", tt.httpPort, tt.socksPort, dontWant, env)
|
||||
t.Errorf("GenerateProxyEnvVars(%q) should not contain %q, got %q", tt.proxyURL, dontWant, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
// Allow all domains directly (for apps that ignore HTTP_PROXY)
|
||||
// The "*" wildcard bypasses proxy-based domain filtering
|
||||
"allowedDomains": ["*"]
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"extends": "code",
|
||||
"filesystem": {
|
||||
// Deny reads by default, only system paths and allowRead are accessible
|
||||
"defaultDenyRead": true,
|
||||
"allowRead": [
|
||||
// Current working directory
|
||||
".",
|
||||
|
||||
// macOS preferences (needed by many apps)
|
||||
"~/Library/Preferences",
|
||||
|
||||
// AI coding tool configs (need to read their own settings)
|
||||
"~/.claude",
|
||||
"~/.claude.json",
|
||||
"~/.codex",
|
||||
"~/.cursor",
|
||||
"~/.opencode",
|
||||
"~/.gemini",
|
||||
"~/.factory",
|
||||
|
||||
// XDG config directory
|
||||
"~/.config",
|
||||
|
||||
// Cache directories (some tools read from cache)
|
||||
"~/.cache"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true,
|
||||
"allowedDomains": [
|
||||
// LLM API providers
|
||||
"api.openai.com",
|
||||
"*.anthropic.com",
|
||||
"api.githubcopilot.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.mistral.ai",
|
||||
"api.cohere.ai",
|
||||
"api.together.xyz",
|
||||
"openrouter.ai",
|
||||
|
||||
// OpenCode
|
||||
"opencode.ai",
|
||||
"api.opencode.ai",
|
||||
|
||||
// Factory CLI (droid)
|
||||
"*.factory.ai",
|
||||
"api.workos.com",
|
||||
|
||||
// Cursor API
|
||||
"*.cursor.sh",
|
||||
|
||||
// Git hosting
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"codeload.github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"release-assets.githubusercontent.com",
|
||||
"gitlab.com",
|
||||
|
||||
// Package registries
|
||||
"registry.npmjs.org",
|
||||
"*.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"pypi.org",
|
||||
"files.pythonhosted.org",
|
||||
"crates.io",
|
||||
"static.crates.io",
|
||||
"index.crates.io",
|
||||
"proxy.golang.org",
|
||||
"sum.golang.org",
|
||||
|
||||
// Model registry
|
||||
"models.dev"
|
||||
],
|
||||
|
||||
"deniedDomains": [
|
||||
// Cloud metadata APIs (prevent credential theft)
|
||||
"169.254.169.254",
|
||||
"metadata.google.internal",
|
||||
"instance-data.ec2.internal",
|
||||
|
||||
// Telemetry (optional, can be removed if needed)
|
||||
"statsig.anthropic.com",
|
||||
"*.sentry.io"
|
||||
]
|
||||
},
|
||||
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
// Temp files
|
||||
"/tmp",
|
||||
|
||||
// Local cache, needed by tools like `uv`
|
||||
"~/.cache/**",
|
||||
|
||||
// Claude Code
|
||||
"~/.claude*",
|
||||
"~/.claude/**",
|
||||
|
||||
// Codex
|
||||
"~/.codex/**",
|
||||
|
||||
// Cursor
|
||||
"~/.cursor/**",
|
||||
|
||||
// OpenCode
|
||||
"~/.opencode/**",
|
||||
"~/.local/state/**",
|
||||
|
||||
// Gemini CLI
|
||||
"~/.gemini/**",
|
||||
|
||||
// Factory CLI (droid)
|
||||
"~/.factory/**",
|
||||
|
||||
// Package manager caches
|
||||
"~/.npm/_cacache",
|
||||
"~/.cache",
|
||||
"~/.bun/**",
|
||||
|
||||
// Cargo cache (Rust, used by Codex)
|
||||
"~/.cargo/registry/**",
|
||||
"~/.cargo/git/**",
|
||||
"~/.cargo/.package-cache",
|
||||
|
||||
// Shell completion cache
|
||||
"~/.zcompdump*",
|
||||
|
||||
// XDG directories for app configs/data
|
||||
"~/.local/share/**",
|
||||
"~/.config/**"
|
||||
],
|
||||
|
||||
"denyWrite": [
|
||||
// Protect environment files with secrets
|
||||
".env",
|
||||
".env.*",
|
||||
"**/.env",
|
||||
"**/.env.*",
|
||||
|
||||
// Protect key/certificate files
|
||||
"*.key",
|
||||
"*.pem",
|
||||
"*.p12",
|
||||
"*.pfx",
|
||||
"**/*.key",
|
||||
"**/*.pem",
|
||||
"**/*.p12",
|
||||
"**/*.pfx"
|
||||
],
|
||||
|
||||
"denyRead": [
|
||||
// SSH private keys and config
|
||||
"~/.ssh/id_*",
|
||||
"~/.ssh/config",
|
||||
"~/.ssh/*.pem",
|
||||
|
||||
// GPG keys
|
||||
"~/.gnupg/**",
|
||||
|
||||
// Cloud provider credentials
|
||||
"~/.aws/**",
|
||||
"~/.config/gcloud/**",
|
||||
"~/.kube/**",
|
||||
|
||||
// Docker config (may contain registry auth)
|
||||
"~/.docker/**",
|
||||
|
||||
// GitHub CLI auth
|
||||
"~/.config/gh/**",
|
||||
|
||||
// Package manager auth tokens
|
||||
"~/.pypirc",
|
||||
"~/.netrc",
|
||||
"~/.git-credentials",
|
||||
"~/.cargo/credentials",
|
||||
"~/.cargo/credentials.toml"
|
||||
]
|
||||
},
|
||||
|
||||
"command": {
|
||||
"useDefaults": true,
|
||||
"deny": [
|
||||
// Git commands that modify remote state
|
||||
"git push",
|
||||
"git reset",
|
||||
"git clean",
|
||||
"git checkout --",
|
||||
"git rebase",
|
||||
"git merge",
|
||||
|
||||
// Package publishing commands
|
||||
"npm publish",
|
||||
"pnpm publish",
|
||||
"yarn publish",
|
||||
"cargo publish",
|
||||
"twine upload",
|
||||
"gem push",
|
||||
|
||||
// Privilege escalation
|
||||
"sudo"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true,
|
||||
"allowedDomains": ["*"],
|
||||
|
||||
// Block common analytics, telemetry, and error reporting services
|
||||
"deniedDomains": [
|
||||
// Error reporting
|
||||
"*.sentry.io",
|
||||
"*.ingest.sentry.io",
|
||||
"sentry.io",
|
||||
|
||||
// Product analytics
|
||||
"*.posthog.com",
|
||||
"app.posthog.com",
|
||||
"us.posthog.com",
|
||||
"eu.posthog.com",
|
||||
|
||||
// Feature flags / experimentation
|
||||
"*.statsig.com",
|
||||
"statsig.com",
|
||||
"statsig.anthropic.com",
|
||||
|
||||
// Customer data platforms
|
||||
"*.segment.io",
|
||||
"*.segment.com",
|
||||
"api.segment.io",
|
||||
"cdn.segment.com",
|
||||
|
||||
// Analytics
|
||||
"*.amplitude.com",
|
||||
"api.amplitude.com",
|
||||
"api2.amplitude.com",
|
||||
"*.mixpanel.com",
|
||||
"api.mixpanel.com",
|
||||
|
||||
"*.heap.io",
|
||||
"*.heapanalytics.com",
|
||||
|
||||
// Session recording
|
||||
"*.fullstory.com",
|
||||
"*.hotjar.com",
|
||||
"*.hotjar.io",
|
||||
"*.logrocket.io",
|
||||
"*.logrocket.com",
|
||||
|
||||
// Error tracking
|
||||
"*.bugsnag.com",
|
||||
"notify.bugsnag.com",
|
||||
"*.rollbar.com",
|
||||
"api.rollbar.com",
|
||||
|
||||
// APM / Monitoring
|
||||
"*.datadog.com",
|
||||
"*.datadoghq.com",
|
||||
|
||||
"*.newrelic.com",
|
||||
"*.nr-data.net",
|
||||
|
||||
// Feature flags
|
||||
"*.launchdarkly.com",
|
||||
"*.split.io",
|
||||
|
||||
// Product analytics / user engagement
|
||||
"*.pendo.io",
|
||||
"*.intercom.io",
|
||||
"*.intercom.com",
|
||||
|
||||
// Mobile attribution
|
||||
"*.appsflyer.com",
|
||||
"*.adjust.com",
|
||||
"*.branch.io",
|
||||
|
||||
// Crash reporting
|
||||
"crashlytics.com",
|
||||
"*.crashlytics.com",
|
||||
"firebase-settings.crashlytics.com"
|
||||
]
|
||||
},
|
||||
|
||||
"filesystem": {
|
||||
"allowWrite": [
|
||||
".",
|
||||
"/tmp",
|
||||
|
||||
// Local cache, needed by tools like `uv`
|
||||
"~/.cache/**",
|
||||
|
||||
// Claude Code state/config
|
||||
"~/.claude*",
|
||||
"~/.claude/**",
|
||||
|
||||
// Codex state/config
|
||||
"~/.codex/**",
|
||||
|
||||
// Package manager caches
|
||||
"~/.npm/_cacache",
|
||||
"~/.cache",
|
||||
"~/.bun/**",
|
||||
|
||||
// Cargo cache (Rust, used by Codex)
|
||||
"~/.cargo/registry/**",
|
||||
"~/.cargo/git/**",
|
||||
"~/.cargo/.package-cache",
|
||||
|
||||
// Shell completion cache
|
||||
"~/.zcompdump*",
|
||||
|
||||
// XDG directories for app configs/data
|
||||
"~/.local/share/**",
|
||||
"~/.config/**",
|
||||
|
||||
// OpenCode state
|
||||
"~/.opencode/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"network": {
|
||||
"allowedDomains": []
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["."],
|
||||
"denyWrite": [".git"]
|
||||
},
|
||||
"command": {
|
||||
"deny": [
|
||||
"git push",
|
||||
"git reset",
|
||||
"git clean",
|
||||
"git checkout --",
|
||||
"git rebase",
|
||||
"git merge"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"network": {
|
||||
"allowLocalBinding": true,
|
||||
"allowLocalOutbound": true
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": [".", "/tmp"]
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
// Package templates provides embedded configuration templates for fence.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// maxExtendsDepth limits inheritance chain depth to prevent infinite loops.
|
||||
const maxExtendsDepth = 10
|
||||
|
||||
// isPath returns true if the extends value looks like a file path rather than a template name.
|
||||
// A value is considered a path if it contains a path separator or starts with ".".
|
||||
func isPath(s string) bool {
|
||||
return strings.ContainsAny(s, "/\\") || strings.HasPrefix(s, ".")
|
||||
}
|
||||
|
||||
//go:embed *.json
|
||||
var templatesFS embed.FS
|
||||
|
||||
// Template represents a named configuration template.
|
||||
type Template struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// AvailableTemplates lists all embedded templates with descriptions.
|
||||
var templateDescriptions = map[string]string{
|
||||
"default-deny": "No network allowlist; no write access (most restrictive)",
|
||||
"disable-telemetry": "Block analytics/error reporting (Sentry, Posthog, Statsig, etc.)",
|
||||
"workspace-write": "Allow writes in the current directory",
|
||||
"npm-install": "Allow npm registry; allow writes to workspace/node_modules/tmp",
|
||||
"pip-install": "Allow PyPI; allow writes to workspace/tmp",
|
||||
"local-dev-server": "Allow binding and localhost outbound; allow writes to workspace/tmp",
|
||||
"git-readonly": "Blocks destructive commands like git push, rm -rf, etc.",
|
||||
"code": "Production-ready config for AI coding agents (Claude Code, Codex, Copilot, etc.)",
|
||||
"code-relaxed": "Like 'code' but allows direct network for apps that ignore HTTP_PROXY (cursor-agent, opencode)",
|
||||
"code-strict": "Like 'code' but denies reads by default; only allows reading the current project directory and essential system paths",
|
||||
}
|
||||
|
||||
// List returns all available template names sorted alphabetically.
|
||||
func List() []Template {
|
||||
entries, err := templatesFS.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var templates []Template
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(entry.Name(), ".json")
|
||||
desc := templateDescriptions[name]
|
||||
if desc == "" {
|
||||
desc = "No description available"
|
||||
}
|
||||
templates = append(templates, Template{Name: name, Description: desc})
|
||||
}
|
||||
|
||||
sort.Slice(templates, func(i, j int) bool {
|
||||
return templates[i].Name < templates[j].Name
|
||||
})
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
// Load loads a template by name and returns the parsed config.
|
||||
// If the template uses "extends", the inheritance chain is resolved.
|
||||
func Load(name string) (*config.Config, error) {
|
||||
return loadWithDepth(name, 0, nil)
|
||||
}
|
||||
|
||||
// loadWithDepth loads a template with cycle and depth tracking.
|
||||
func loadWithDepth(name string, depth int, seen map[string]bool) (*config.Config, error) {
|
||||
if depth > maxExtendsDepth {
|
||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
||||
}
|
||||
|
||||
// Normalize name (remove .json if present)
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
|
||||
// Check for cycles
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("circular extends detected: %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
filename := name + ".json"
|
||||
data, err := templatesFS.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
|
||||
}
|
||||
|
||||
// If this template extends another, resolve the chain
|
||||
if cfg.Extends != "" {
|
||||
baseCfg, err := loadWithDepth(cfg.Extends, depth+1, seen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load base template %q: %w", cfg.Extends, err)
|
||||
}
|
||||
return config.Merge(baseCfg, &cfg), nil
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Exists checks if a template with the given name exists.
|
||||
func Exists(name string) bool {
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
filename := name + ".json"
|
||||
|
||||
_, err := templatesFS.ReadFile(filename)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetPath returns the embedded path for a template (for display purposes).
|
||||
func GetPath(name string) string {
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
return filepath.Join("internal/templates", name+".json")
|
||||
}
|
||||
|
||||
// ResolveExtends resolves the extends field in a config by loading and merging
|
||||
// the base template or config file. If the config has no extends field, it is returned as-is.
|
||||
// Relative paths are resolved relative to the current working directory.
|
||||
// Use ResolveExtendsWithBaseDir if you need to resolve relative to a specific directory.
|
||||
func ResolveExtends(cfg *config.Config) (*config.Config, error) {
|
||||
return ResolveExtendsWithBaseDir(cfg, "")
|
||||
}
|
||||
|
||||
// ResolveExtendsWithBaseDir resolves the extends field in a config.
|
||||
// The baseDir is used to resolve relative paths in the extends field.
|
||||
// If baseDir is empty, relative paths will be resolved relative to the current working directory.
|
||||
//
|
||||
// The extends field can be:
|
||||
// - A template name (e.g., "code", "npm-install")
|
||||
// - An absolute path (e.g., "/path/to/base.json")
|
||||
// - A relative path (e.g., "./base.json", "../shared/base.json")
|
||||
//
|
||||
// Paths are detected by the presence of "/" or "\" or a leading ".".
|
||||
func ResolveExtendsWithBaseDir(cfg *config.Config, baseDir string) (*config.Config, error) {
|
||||
if cfg == nil || cfg.Extends == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
return resolveExtendsWithDepth(cfg, baseDir, 0, nil)
|
||||
}
|
||||
|
||||
// resolveExtendsWithDepth resolves extends with cycle and depth tracking.
|
||||
func resolveExtendsWithDepth(cfg *config.Config, baseDir string, depth int, seen map[string]bool) (*config.Config, error) {
|
||||
if cfg == nil || cfg.Extends == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if depth > maxExtendsDepth {
|
||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
||||
}
|
||||
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
|
||||
var baseCfg *config.Config
|
||||
var newBaseDir string
|
||||
var err error
|
||||
|
||||
// Handle file path or template name extends
|
||||
if isPath(cfg.Extends) {
|
||||
baseCfg, newBaseDir, err = loadConfigFile(cfg.Extends, baseDir, seen)
|
||||
} else {
|
||||
baseCfg, err = loadWithDepth(cfg.Extends, depth+1, seen)
|
||||
newBaseDir = ""
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the base config also has extends, resolve it recursively
|
||||
if baseCfg.Extends != "" {
|
||||
baseCfg, err = resolveExtendsWithDepth(baseCfg, newBaseDir, depth+1, seen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return config.Merge(baseCfg, cfg), nil
|
||||
}
|
||||
|
||||
// loadConfigFile loads a config from a file path with cycle detection.
|
||||
// Returns the loaded config, the directory of the loaded file (for resolving nested extends), and any error.
|
||||
func loadConfigFile(path, baseDir string, seen map[string]bool) (*config.Config, string, error) {
|
||||
var resolvedPath string
|
||||
switch {
|
||||
case filepath.IsAbs(path):
|
||||
resolvedPath = path
|
||||
case baseDir != "":
|
||||
resolvedPath = filepath.Join(baseDir, path)
|
||||
default:
|
||||
var err error
|
||||
resolvedPath, err = filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to resolve path %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean and normalize the path for cycle detection
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
|
||||
if seen[resolvedPath] {
|
||||
return nil, "", fmt.Errorf("circular extends detected: %q", path)
|
||||
}
|
||||
seen[resolvedPath] = true
|
||||
|
||||
data, err := os.ReadFile(resolvedPath) //nolint:gosec // user-provided config path - intentional
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", fmt.Errorf("extends file not found: %q", path)
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to read extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
// Handle empty file
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, "", fmt.Errorf("extends file is empty: %q", path)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid JSON in extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid configuration in extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, filepath.Dir(resolvedPath), nil
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
templates := List()
|
||||
if len(templates) == 0 {
|
||||
t.Fatal("expected at least one template")
|
||||
}
|
||||
|
||||
// Check that code template exists
|
||||
found := false
|
||||
for _, tmpl := range templates {
|
||||
if tmpl.Name == "code" {
|
||||
found = true
|
||||
if tmpl.Description == "" {
|
||||
t.Error("code template should have a description")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("code template not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantErr bool
|
||||
}{
|
||||
{"code", false},
|
||||
{"disable-telemetry", false},
|
||||
{"git-readonly", false},
|
||||
{"local-dev-server", false},
|
||||
{"nonexistent", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg, err := Load(tt.name)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("expected config, got nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithJsonExtension(t *testing.T) {
|
||||
// Should work with or without .json extension
|
||||
cfg1, err := Load("disable-telemetry")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load disable-telemetry: %v", err)
|
||||
}
|
||||
|
||||
cfg2, err := Load("disable-telemetry.json")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load disable-telemetry.json: %v", err)
|
||||
}
|
||||
|
||||
// Both should return valid configs
|
||||
if cfg1 == nil || cfg2 == nil {
|
||||
t.Error("expected both configs to be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExists(t *testing.T) {
|
||||
if !Exists("code") {
|
||||
t.Error("code template should exist")
|
||||
}
|
||||
if Exists("nonexistent") {
|
||||
t.Error("nonexistent should not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeTemplate(t *testing.T) {
|
||||
cfg, err := Load("code")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load code template: %v", err)
|
||||
}
|
||||
|
||||
// Verify key settings
|
||||
if !cfg.AllowPty {
|
||||
t.Error("code template should have AllowPty=true")
|
||||
}
|
||||
|
||||
if len(cfg.Network.AllowedDomains) == 0 {
|
||||
t.Error("code template should have allowed domains")
|
||||
}
|
||||
|
||||
// Check that *.anthropic.com is in allowed domains
|
||||
found := false
|
||||
for _, domain := range cfg.Network.AllowedDomains {
|
||||
if domain == "*.anthropic.com" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("*.anthropic.com should be in allowed domains")
|
||||
}
|
||||
|
||||
// Check that cloud metadata domains are denied
|
||||
if len(cfg.Network.DeniedDomains) == 0 {
|
||||
t.Error("code template should have denied domains")
|
||||
}
|
||||
|
||||
// Check command deny list
|
||||
if len(cfg.Command.Deny) == 0 {
|
||||
t.Error("code template should have denied commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeStrictTemplate(t *testing.T) {
|
||||
cfg, err := Load("code-strict")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load code-strict template: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty from code template
|
||||
if !cfg.AllowPty {
|
||||
t.Error("code-strict should inherit AllowPty=true from code")
|
||||
}
|
||||
|
||||
// Should have defaultDenyRead enabled
|
||||
if !cfg.Filesystem.DefaultDenyRead {
|
||||
t.Error("code-strict should have DefaultDenyRead=true")
|
||||
}
|
||||
|
||||
// Should have allowRead with current directory
|
||||
if len(cfg.Filesystem.AllowRead) == 0 {
|
||||
t.Error("code-strict should have allowRead paths")
|
||||
}
|
||||
hasCurrentDir := false
|
||||
for _, path := range cfg.Filesystem.AllowRead {
|
||||
if path == "." {
|
||||
hasCurrentDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCurrentDir {
|
||||
t.Error("code-strict should allow reading current directory")
|
||||
}
|
||||
|
||||
// Should inherit allowWrite from code
|
||||
if len(cfg.Filesystem.AllowWrite) == 0 {
|
||||
t.Error("code-strict should inherit allowWrite from code")
|
||||
}
|
||||
|
||||
// Should inherit denyWrite from code
|
||||
if len(cfg.Filesystem.DenyWrite) == 0 {
|
||||
t.Error("code-strict should inherit denyWrite from code")
|
||||
}
|
||||
|
||||
// Should inherit allowed domains from code
|
||||
if len(cfg.Network.AllowedDomains) == 0 {
|
||||
t.Error("code-strict should inherit allowed domains from code")
|
||||
}
|
||||
|
||||
// Should inherit denied commands from code
|
||||
if len(cfg.Command.Deny) == 0 {
|
||||
t.Error("code-strict should inherit denied commands from code")
|
||||
}
|
||||
|
||||
// Extends should be cleared after resolution
|
||||
if cfg.Extends != "" {
|
||||
t.Error("extends should be cleared after loading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeRelaxedTemplate(t *testing.T) {
|
||||
cfg, err := Load("code-relaxed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load code-relaxed template: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty from code template
|
||||
if !cfg.AllowPty {
|
||||
t.Error("code-relaxed should inherit AllowPty=true from code")
|
||||
}
|
||||
|
||||
// Should have wildcard in allowed domains
|
||||
hasWildcard := false
|
||||
for _, domain := range cfg.Network.AllowedDomains {
|
||||
if domain == "*" {
|
||||
hasWildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWildcard {
|
||||
t.Error("code-relaxed should have '*' in allowed domains")
|
||||
}
|
||||
|
||||
// Should inherit denied domains from code
|
||||
if len(cfg.Network.DeniedDomains) == 0 {
|
||||
t.Error("code-relaxed should inherit denied domains from code")
|
||||
}
|
||||
|
||||
// Should inherit filesystem config from code
|
||||
if len(cfg.Filesystem.AllowWrite) == 0 {
|
||||
t.Error("code-relaxed should inherit allowWrite from code")
|
||||
}
|
||||
if len(cfg.Filesystem.DenyRead) == 0 {
|
||||
t.Error("code-relaxed should inherit denyRead from code")
|
||||
}
|
||||
if len(cfg.Filesystem.DenyWrite) == 0 {
|
||||
t.Error("code-relaxed should inherit denyWrite from code")
|
||||
}
|
||||
|
||||
// Should inherit command config from code
|
||||
if len(cfg.Command.Deny) == 0 {
|
||||
t.Error("code-relaxed should inherit command deny list from code")
|
||||
}
|
||||
|
||||
// Extends should be cleared after resolution
|
||||
if cfg.Extends != "" {
|
||||
t.Error("extends should be cleared after loading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExtends(t *testing.T) {
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
result, err := ResolveExtends(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil result for nil input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no extends", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
AllowPty: true,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
result, err := ResolveExtends(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result != cfg {
|
||||
t.Error("expected same config when no extends")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends code template", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "code",
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"private-registry.company.com"},
|
||||
},
|
||||
}
|
||||
result, err := ResolveExtends(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have merged config
|
||||
if result.Extends != "" {
|
||||
t.Error("extends should be cleared after resolution")
|
||||
}
|
||||
|
||||
// Should have AllowPty from base template
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from code template")
|
||||
}
|
||||
|
||||
// Should have domains from both
|
||||
hasPrivateRegistry := false
|
||||
hasAnthropic := false
|
||||
for _, domain := range result.Network.AllowedDomains {
|
||||
if domain == "private-registry.company.com" {
|
||||
hasPrivateRegistry = true
|
||||
}
|
||||
if domain == "*.anthropic.com" {
|
||||
hasAnthropic = true
|
||||
}
|
||||
}
|
||||
if !hasPrivateRegistry {
|
||||
t.Error("should have private-registry.company.com from override")
|
||||
}
|
||||
if !hasAnthropic {
|
||||
t.Error("should have *.anthropic.com from base template")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends nonexistent template", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "nonexistent-template",
|
||||
}
|
||||
_, err := ResolveExtends(cfg)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent template")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtendsChainDepth(t *testing.T) {
|
||||
// This tests that the maxExtendsDepth limit is respected.
|
||||
// We can't easily create a deep chain with embedded templates,
|
||||
// but we can test that the code template (which has no extends)
|
||||
// loads correctly.
|
||||
cfg, err := Load("code")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Error("expected non-nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
// Template names (not paths)
|
||||
{"code", false},
|
||||
{"npm-install", false},
|
||||
{"my-template", false},
|
||||
|
||||
// Absolute paths
|
||||
{"/path/to/config.json", true},
|
||||
{"/etc/fence/base.json", true},
|
||||
|
||||
// Relative paths
|
||||
{"./base.json", true},
|
||||
{"../shared/base.json", true},
|
||||
{"configs/base.json", true},
|
||||
|
||||
// Windows-style paths
|
||||
{"C:\\path\\to\\config.json", true},
|
||||
{".\\base.json", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := isPath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("isPath(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendsFilePath(t *testing.T) {
|
||||
// Create temp directory for test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Run("extends absolute path", func(t *testing.T) {
|
||||
// Create base config file
|
||||
baseContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["base.example.com"]
|
||||
},
|
||||
"filesystem": {
|
||||
"allowWrite": ["/tmp"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(tmpDir, "base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends the base via absolute path
|
||||
cfg := &config.Config{
|
||||
Extends: basePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"override.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have merged domains
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
|
||||
// Should have filesystem from base
|
||||
if len(result.Filesystem.AllowWrite) != 1 || result.Filesystem.AllowWrite[0] != "/tmp" {
|
||||
t.Errorf("expected AllowWrite [/tmp], got %v", result.Filesystem.AllowWrite)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends relative path", func(t *testing.T) {
|
||||
// Create base config in subdir
|
||||
subDir := filepath.Join(tmpDir, "configs")
|
||||
if err := os.MkdirAll(subDir, 0o750); err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
baseContent := `{
|
||||
"allowPty": true,
|
||||
"network": {
|
||||
"allowedDomains": ["relative-base.example.com"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(subDir, "base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends via relative path
|
||||
cfg := &config.Config{
|
||||
Extends: "./configs/base.json",
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"child.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should inherit AllowPty
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from base")
|
||||
}
|
||||
|
||||
// Should have merged domains
|
||||
if len(result.Network.AllowedDomains) != 2 {
|
||||
t.Errorf("expected 2 domains, got %d", len(result.Network.AllowedDomains))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends nonexistent file", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Extends: "/nonexistent/path/config.json",
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends invalid JSON file", func(t *testing.T) {
|
||||
invalidPath := filepath.Join(tmpDir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write invalid config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: invalidPath,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extends file with invalid config", func(t *testing.T) {
|
||||
// Create config with invalid domain pattern
|
||||
invalidContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["*.com"]
|
||||
}
|
||||
}`
|
||||
invalidPath := filepath.Join(tmpDir, "invalid-domain.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(invalidContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: invalidPath,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("circular extends via files", func(t *testing.T) {
|
||||
// Create two files that extend each other
|
||||
fileA := filepath.Join(tmpDir, "a.json")
|
||||
fileB := filepath.Join(tmpDir, "b.json")
|
||||
|
||||
contentA := `{"extends": "` + fileB + `"}`
|
||||
contentB := `{"extends": "` + fileA + `"}`
|
||||
|
||||
if err := os.WriteFile(fileA, []byte(contentA), 0o600); err != nil {
|
||||
t.Fatalf("failed to write a.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fileB, []byte(contentB), 0o600); err != nil {
|
||||
t.Fatalf("failed to write b.json: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: fileA,
|
||||
}
|
||||
|
||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for circular extends")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested extends chain", func(t *testing.T) {
|
||||
// Create a chain: child -> middle -> base
|
||||
baseContent := `{
|
||||
"network": {
|
||||
"allowedDomains": ["base.com"]
|
||||
}
|
||||
}`
|
||||
basePath := filepath.Join(tmpDir, "chain-base.json")
|
||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write base: %v", err)
|
||||
}
|
||||
|
||||
middleContent := `{
|
||||
"extends": "` + basePath + `",
|
||||
"network": {
|
||||
"allowedDomains": ["middle.com"]
|
||||
}
|
||||
}`
|
||||
middlePath := filepath.Join(tmpDir, "chain-middle.json")
|
||||
if err := os.WriteFile(middlePath, []byte(middleContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write middle: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Extends: middlePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"child.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have all three domains
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file extends template", func(t *testing.T) {
|
||||
// Create a file that extends a built-in template
|
||||
fileContent := `{
|
||||
"extends": "code",
|
||||
"network": {
|
||||
"allowedDomains": ["extra.example.com"]
|
||||
}
|
||||
}`
|
||||
filePath := filepath.Join(tmpDir, "extends-template.json")
|
||||
if err := os.WriteFile(filePath, []byte(fileContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
// Config that extends this file
|
||||
cfg := &config.Config{
|
||||
Extends: filePath,
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"top.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have AllowPty from code template
|
||||
if !result.AllowPty {
|
||||
t.Error("should inherit AllowPty from code template")
|
||||
}
|
||||
|
||||
// Should have domains from all levels
|
||||
hasAnthropic := false
|
||||
hasExtra := false
|
||||
hasTop := false
|
||||
for _, domain := range result.Network.AllowedDomains {
|
||||
switch domain {
|
||||
case "*.anthropic.com":
|
||||
hasAnthropic = true
|
||||
case "extra.example.com":
|
||||
hasExtra = true
|
||||
case "top.example.com":
|
||||
hasTop = true
|
||||
}
|
||||
}
|
||||
if !hasAnthropic {
|
||||
t.Error("should have *.anthropic.com from code template")
|
||||
}
|
||||
if !hasExtra {
|
||||
t.Error("should have extra.example.com from middle file")
|
||||
}
|
||||
if !hasTop {
|
||||
t.Error("should have top.example.com from top config")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Package fence provides a public API for sandboxing commands.
|
||||
package fence
|
||||
// Package greywall provides a public API for sandboxing commands.
|
||||
package greywall
|
||||
|
||||
import (
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/Use-Tusk/fence/internal/platform"
|
||||
"github.com/Use-Tusk/fence/internal/sandbox"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
||||
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
|
||||
)
|
||||
|
||||
// IsSupported returns true if the current platform supports sandboxing (macOS/Linux).
|
||||
@@ -12,7 +12,7 @@ func IsSupported() bool {
|
||||
return platform.IsSupported()
|
||||
}
|
||||
|
||||
// Config is the configuration for fence.
|
||||
// Config is the configuration for greywall.
|
||||
type Config = config.Config
|
||||
|
||||
// NetworkConfig defines network restrictions.
|
||||
@@ -10,7 +10,7 @@
|
||||
# ./scripts/benchmark.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# -b, --binary PATH Path to fence binary (default: ./fence or builds one)
|
||||
# -b, --binary PATH Path to greywall binary (default: ./greywall or builds one)
|
||||
# -o, --output DIR Output directory for results (default: ./benchmarks)
|
||||
# -n, --runs N Minimum runs per benchmark (default: 30)
|
||||
# -q, --quick Quick mode: fewer runs, skip slow benchmarks
|
||||
@@ -19,7 +19,7 @@
|
||||
#
|
||||
# Requirements:
|
||||
# - hyperfine (brew install hyperfine / apt install hyperfine)
|
||||
# - go (for building fence if needed)
|
||||
# - go (for building greywall if needed)
|
||||
# - Optional: python3 (for local-server.py network benchmarks)
|
||||
|
||||
set -euo pipefail
|
||||
@@ -32,7 +32,7 @@ BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Defaults
|
||||
FENCE_BIN=""
|
||||
GREYWALL_BIN=""
|
||||
OUTPUT_DIR="./benchmarks"
|
||||
MIN_RUNS=30
|
||||
WARMUP=3
|
||||
@@ -43,7 +43,7 @@ NETWORK=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-b|--binary)
|
||||
FENCE_BIN="$2"
|
||||
GREYWALL_BIN="$2"
|
||||
shift 2
|
||||
;;
|
||||
-o|--output)
|
||||
@@ -75,21 +75,21 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Find or build fence binary
|
||||
if [[ -z "$FENCE_BIN" ]]; then
|
||||
if [[ -x "./fence" ]]; then
|
||||
FENCE_BIN="./fence"
|
||||
elif [[ -x "./dist/fence" ]]; then
|
||||
FENCE_BIN="./dist/fence"
|
||||
# Find or build greywall binary
|
||||
if [[ -z "$GREYWALL_BIN" ]]; then
|
||||
if [[ -x "./greywall" ]]; then
|
||||
GREYWALL_BIN="./greywall"
|
||||
elif [[ -x "./dis./greywall" ]]; then
|
||||
GREYWALL_BIN="./dis./greywall"
|
||||
else
|
||||
echo -e "${BLUE}Building fence...${NC}"
|
||||
go build -o ./fence ./cmd/fence
|
||||
FENCE_BIN="./fence"
|
||||
echo -e "${BLUE}Building greywall...${NC}"
|
||||
go build -o ./greywall ./cm./greywall
|
||||
GREYWALL_BIN="./greywall"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -x "$FENCE_BIN" ]]; then
|
||||
echo -e "${RED}Error: fence binary not found at $FENCE_BIN${NC}"
|
||||
if [[ ! -x "$GREYWALL_BIN" ]]; then
|
||||
echo -e "${RED}Error: greywall binary not found at $GREYWALL_BIN${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -109,7 +109,7 @@ WORKSPACE=$(mktemp -d -p .)
|
||||
trap 'rm -rf "$WORKSPACE"' EXIT
|
||||
|
||||
# Create settings file for sandbox
|
||||
SETTINGS_FILE="$WORKSPACE/fence.json"
|
||||
SETTINGS_FILE="$WORKSPAC./greywall.json"
|
||||
cat > "$SETTINGS_FILE" << EOF
|
||||
{
|
||||
"filesystem": {
|
||||
@@ -131,13 +131,13 @@ RESULTS_MD="$OUTPUT_DIR/${OS,,}-${ARCH}-${TIMESTAMP}.md"
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}==========================================${NC}"
|
||||
echo -e "${BLUE}Fence Sandbox Benchmarks${NC}"
|
||||
echo -e "${BLUE}Greywall Sandbox Benchmarks${NC}"
|
||||
echo -e "${BLUE}==========================================${NC}"
|
||||
echo ""
|
||||
echo "Platform: $OS $ARCH"
|
||||
echo "Kernel: $KERNEL"
|
||||
echo "Date: $DATE"
|
||||
echo "Fence: $FENCE_BIN"
|
||||
echo "Greywall: $GREYWALL_BIN"
|
||||
echo "Output: $OUTPUT_DIR"
|
||||
echo "Min runs: $MIN_RUNS"
|
||||
echo ""
|
||||
@@ -169,11 +169,11 @@ echo ""
|
||||
|
||||
run_bench "true" \
|
||||
--command-name "unsandboxed" "true" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -- true"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -- true"
|
||||
|
||||
run_bench "echo" \
|
||||
--command-name "unsandboxed" "echo hello >/dev/null" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -c 'echo hello' >/dev/null"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -c 'echo hello' >/dev/null"
|
||||
|
||||
# ============================================================================
|
||||
# Tool compatibility benchmarks
|
||||
@@ -185,7 +185,7 @@ echo ""
|
||||
if command -v python3 &> /dev/null; then
|
||||
run_bench "python" \
|
||||
--command-name "unsandboxed" "python3 -c 'pass'" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -c \"python3 -c 'pass'\""
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -c \"python3 -c 'pass'\""
|
||||
else
|
||||
echo -e "${YELLOW}Skipping python3 (not found)${NC}"
|
||||
fi
|
||||
@@ -193,7 +193,7 @@ fi
|
||||
if command -v node &> /dev/null && [[ "$QUICK" == "false" ]]; then
|
||||
run_bench "node" \
|
||||
--command-name "unsandboxed" "node -e ''" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -c \"node -e ''\""
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -c \"node -e ''\""
|
||||
else
|
||||
echo -e "${YELLOW}Skipping node (not found or quick mode)${NC}"
|
||||
fi
|
||||
@@ -208,7 +208,7 @@ echo ""
|
||||
if command -v git &> /dev/null && [[ -d .git ]]; then
|
||||
run_bench "git-status" \
|
||||
--command-name "unsandboxed" "git status --porcelain >/dev/null" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -- git status --porcelain >/dev/null"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -- git status --porcelain >/dev/null"
|
||||
else
|
||||
echo -e "${YELLOW}Skipping git status (not in a git repo)${NC}"
|
||||
fi
|
||||
@@ -216,7 +216,7 @@ fi
|
||||
if command -v rg &> /dev/null && [[ "$QUICK" == "false" ]]; then
|
||||
run_bench "ripgrep" \
|
||||
--command-name "unsandboxed" "rg -n 'package' -S . >/dev/null 2>&1 || true" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -c \"rg -n 'package' -S . >/dev/null 2>&1\" || true"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -c \"rg -n 'package' -S . >/dev/null 2>&1\" || true"
|
||||
else
|
||||
echo -e "${YELLOW}Skipping ripgrep (not found or quick mode)${NC}"
|
||||
fi
|
||||
@@ -230,11 +230,11 @@ echo ""
|
||||
|
||||
run_bench "file-write" \
|
||||
--command-name "unsandboxed" "echo 'test' > $WORKSPACE/test.txt" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -c \"echo 'test' > $WORKSPACE/test.txt\""
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -c \"echo 'test' > $WORKSPACE/test.txt\""
|
||||
|
||||
run_bench "file-read" \
|
||||
--command-name "unsandboxed" "cat $WORKSPACE/test.txt >/dev/null" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -c 'cat $WORKSPACE/test.txt' >/dev/null"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -c 'cat $WORKSPACE/test.txt' >/dev/null"
|
||||
|
||||
# ============================================================================
|
||||
# Monitor mode benchmarks (optional)
|
||||
@@ -245,8 +245,8 @@ if [[ "$QUICK" == "false" ]]; then
|
||||
echo ""
|
||||
|
||||
run_bench "monitor-true" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $SETTINGS_FILE -- true" \
|
||||
--command-name "sandboxed+monitor" "$FENCE_BIN -m -s $SETTINGS_FILE -- true"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $SETTINGS_FILE -- true" \
|
||||
--command-name "sandboxed+monitor" "$GREYWALL_BIN -m -s $SETTINGS_FILE -- true"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
@@ -266,7 +266,7 @@ if [[ "$NETWORK" == "true" ]]; then
|
||||
sleep 1
|
||||
|
||||
# Create network settings
|
||||
NET_SETTINGS="$WORKSPACE/fence-net.json"
|
||||
NET_SETTINGS="$WORKSPAC./greywall-net.json"
|
||||
cat > "$NET_SETTINGS" << EOF
|
||||
{
|
||||
"network": {
|
||||
@@ -281,7 +281,7 @@ EOF
|
||||
if command -v curl &> /dev/null; then
|
||||
run_bench "network-curl" \
|
||||
--command-name "unsandboxed" "curl -s http://127.0.0.1:8765/ >/dev/null" \
|
||||
--command-name "sandboxed" "$FENCE_BIN -s $NET_SETTINGS -c 'curl -s http://127.0.0.1:8765/' >/dev/null"
|
||||
--command-name "sandboxed" "$GREYWALL_BIN -s $NET_SETTINGS -c 'curl -s http://127.0.0.1:8765/' >/dev/null"
|
||||
fi
|
||||
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
@@ -303,7 +303,7 @@ echo " \"platform\": \"$OS\"," >> "$RESULTS_JSON"
|
||||
echo " \"arch\": \"$ARCH\"," >> "$RESULTS_JSON"
|
||||
echo " \"kernel\": \"$KERNEL\"," >> "$RESULTS_JSON"
|
||||
echo " \"date\": \"$DATE\"," >> "$RESULTS_JSON"
|
||||
echo " \"fence_version\": \"$($FENCE_BIN --version 2>/dev/null || echo unknown)\"," >> "$RESULTS_JSON"
|
||||
echo " \"greywall_version\": \"$($GREYWALL_BIN --version 2>/dev/null || echo unknown)\"," >> "$RESULTS_JSON"
|
||||
echo " \"benchmarks\": {" >> "$RESULTS_JSON"
|
||||
|
||||
first=true
|
||||
@@ -324,12 +324,12 @@ echo "}" >> "$RESULTS_JSON"
|
||||
|
||||
# Generate Markdown report
|
||||
cat > "$RESULTS_MD" << EOF
|
||||
# Fence Benchmark Results
|
||||
# Greywall Benchmark Results
|
||||
|
||||
**Platform:** $OS $ARCH
|
||||
**Kernel:** $KERNEL
|
||||
**Date:** $DATE
|
||||
**Fence:** $($FENCE_BIN --version 2>/dev/null || echo unknown)
|
||||
**Greywall:** $($GREYWALL_BIN --version 2>/dev/null || echo unknown)
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -150,4 +150,4 @@ git push origin "$NEW_VERSION"
|
||||
echo ""
|
||||
info "✓ Released $NEW_VERSION"
|
||||
info "GitHub Actions will now build and publish the release."
|
||||
info "Watch progress at: https://github.com/Use-Tusk/fence/actions"
|
||||
info "Watch progress at: https://gitea.app.monadical.io/monadical/greywall/actions"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/bin/bash
|
||||
# smoke_test.sh - Run smoke tests against the fence binary
|
||||
# smoke_test.sh - Run smoke tests against the greywall binary
|
||||
#
|
||||
# This script tests the compiled fence binary to ensure basic functionality works.
|
||||
# This script tests the compiled greywall binary to ensure basic functionality works.
|
||||
# Unlike integration tests (which test internal APIs), smoke tests verify the
|
||||
# final artifact behaves correctly.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/smoke_test.sh [path-to-fence-binary]
|
||||
# ./scripts/smoke_test.sh [path-to-greywall-binary]
|
||||
#
|
||||
# If no path is provided, it will look for ./fence or use 'go run'.
|
||||
# If no path is provided, it will look for ./greywall or use 'go run'.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -21,25 +21,25 @@ PASSED=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
|
||||
FENCE_BIN="${1:-}"
|
||||
if [[ -z "$FENCE_BIN" ]]; then
|
||||
if [[ -x "./fence" ]]; then
|
||||
FENCE_BIN="./fence"
|
||||
elif [[ -x "./dist/fence" ]]; then
|
||||
FENCE_BIN="./dist/fence"
|
||||
GREYWALL_BIN="${1:-}"
|
||||
if [[ -z "$GREYWALL_BIN" ]]; then
|
||||
if [[ -x "./greywall" ]]; then
|
||||
GREYWALL_BIN="./greywall"
|
||||
elif [[ -x "./dis./greywall" ]]; then
|
||||
GREYWALL_BIN="./dis./greywall"
|
||||
else
|
||||
echo "Building fence..."
|
||||
go build -o ./fence ./cmd/fence
|
||||
FENCE_BIN="./fence"
|
||||
echo "Building greywall..."
|
||||
go build -o ./greywall ./cm./greywall
|
||||
GREYWALL_BIN="./greywall"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -x "$FENCE_BIN" ]]; then
|
||||
echo "Error: fence binary not found at $FENCE_BIN"
|
||||
if [[ ! -x "$GREYWALL_BIN" ]]; then
|
||||
echo "Error: greywall binary not found at $GREYWALL_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using fence binary: $FENCE_BIN"
|
||||
echo "Using greywall binary: $GREYWALL_BIN"
|
||||
echo "=============================================="
|
||||
|
||||
# Create temp workspace in current directory (not /tmp, which gets overlaid by bwrap --tmpfs)
|
||||
@@ -100,16 +100,16 @@ echo "=== Basic Functionality ==="
|
||||
echo ""
|
||||
|
||||
# Test: Version flag works
|
||||
run_test "version flag" "pass" "$FENCE_BIN" --version
|
||||
run_test "version flag" "pass" "$GREYWALL_BIN" --version
|
||||
|
||||
# Test: Echo works
|
||||
run_test "echo command" "pass" "$FENCE_BIN" -c "echo hello"
|
||||
run_test "echo command" "pass" "$GREYWALL_BIN" -c "echo hello"
|
||||
|
||||
# Test: ls works
|
||||
run_test "ls command" "pass" "$FENCE_BIN" -- ls
|
||||
run_test "ls command" "pass" "$GREYWALL_BIN" -- ls
|
||||
|
||||
# Test: pwd works
|
||||
run_test "pwd command" "pass" "$FENCE_BIN" -- pwd
|
||||
run_test "pwd command" "pass" "$GREYWALL_BIN" -- pwd
|
||||
|
||||
echo ""
|
||||
echo "=== Filesystem Restrictions ==="
|
||||
@@ -117,11 +117,11 @@ echo ""
|
||||
|
||||
# Test: Read existing file works
|
||||
echo "test content" > "$WORKSPACE/test.txt"
|
||||
run_test "read file in workspace" "pass" "$FENCE_BIN" -c "cat $WORKSPACE/test.txt"
|
||||
run_test "read file in workspace" "pass" "$GREYWALL_BIN" -c "cat $WORKSPACE/test.txt"
|
||||
|
||||
# Test: Write outside workspace blocked
|
||||
# Create a settings file that only allows write to current workspace
|
||||
SETTINGS_FILE="$WORKSPACE/fence.json"
|
||||
SETTINGS_FILE="$WORKSPAC./greywall.json"
|
||||
cat > "$SETTINGS_FILE" << EOF
|
||||
{
|
||||
"filesystem": {
|
||||
@@ -131,14 +131,14 @@ cat > "$SETTINGS_FILE" << EOF
|
||||
EOF
|
||||
|
||||
# Note: Use /var/tmp since /tmp is mounted as tmpfs (writable but ephemeral) inside the sandbox
|
||||
OUTSIDE_FILE="/var/tmp/outside-fence-test-$$.txt"
|
||||
run_test "write outside workspace blocked" "fail" "$FENCE_BIN" -s "$SETTINGS_FILE" -c "touch $OUTSIDE_FILE"
|
||||
OUTSIDE_FILE="/var/tmp/outside-greywall-test-$$.txt"
|
||||
run_test "write outside workspace blocked" "fail" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "touch $OUTSIDE_FILE"
|
||||
|
||||
# Cleanup in case it wasn't blocked
|
||||
rm -f "$OUTSIDE_FILE" 2>/dev/null || true
|
||||
|
||||
# Test: Write inside workspace allowed (using the workspace path in -c)
|
||||
run_test "write inside workspace allowed" "pass" "$FENCE_BIN" -s "$SETTINGS_FILE" -c "touch $WORKSPACE/new-file.txt"
|
||||
run_test "write inside workspace allowed" "pass" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "touch $WORKSPACE/new-file.txt"
|
||||
|
||||
# Check file was actually created
|
||||
if [[ -f "$WORKSPACE/new-file.txt" ]]; then
|
||||
@@ -166,16 +166,16 @@ cat > "$SETTINGS_FILE" << EOF
|
||||
EOF
|
||||
|
||||
# Test: Denied command is blocked
|
||||
run_test "blocked command (rm -rf)" "fail" "$FENCE_BIN" -s "$SETTINGS_FILE" -c "rm -rf /tmp/test"
|
||||
run_test "blocked command (rm -rf)" "fail" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "rm -rf /tmp/test"
|
||||
|
||||
# Test: Similar but not blocked command works (rm without -rf)
|
||||
run_test "allowed command (echo)" "pass" "$FENCE_BIN" -s "$SETTINGS_FILE" -c "echo safe command"
|
||||
run_test "allowed command (echo)" "pass" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "echo safe command"
|
||||
|
||||
# Test: Chained command with blocked command
|
||||
run_test "chained blocked command" "fail" "$FENCE_BIN" -s "$SETTINGS_FILE" -c "ls && rm -rf /tmp/test"
|
||||
run_test "chained blocked command" "fail" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "ls && rm -rf /tmp/test"
|
||||
|
||||
# Test: Nested shell with blocked command
|
||||
run_test "nested shell blocked command" "fail" "$FENCE_BIN" -s "$SETTINGS_FILE" -c 'bash -c "rm -rf /tmp/test"'
|
||||
run_test "nested shell blocked command" "fail" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c 'bash -c "rm -rf /tmp/test"'
|
||||
|
||||
echo ""
|
||||
echo "=== Network Restrictions ==="
|
||||
@@ -196,7 +196,7 @@ EOF
|
||||
if command_exists curl; then
|
||||
# Test: Network blocked by default - curl should fail or return blocked message
|
||||
# Use curl's own timeout (no need for external timeout command)
|
||||
output=$("$FENCE_BIN" -s "$SETTINGS_FILE" -c "curl -s --connect-timeout 2 --max-time 3 http://example.com" 2>&1) || true
|
||||
output=$("$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "curl -s --connect-timeout 2 --max-time 3 http://example.com" 2>&1) || true
|
||||
if echo "$output" | grep -qi "blocked\|refused\|denied\|timeout\|error"; then
|
||||
echo -e "Testing: network blocked (curl)... ${GREEN}PASS${NC}"
|
||||
PASSED=$((PASSED + 1))
|
||||
@@ -218,8 +218,8 @@ else
|
||||
skip_test "network blocked (curl)" "curl not installed"
|
||||
fi
|
||||
|
||||
# Test with allowed domain (only if FENCE_TEST_NETWORK is set)
|
||||
if [[ "${FENCE_TEST_NETWORK:-}" == "1" ]]; then
|
||||
# Test with allowed domain (only if GREYWALL_TEST_NETWORK is set)
|
||||
if [[ "${GREYWALL_TEST_NETWORK:-}" == "1" ]]; then
|
||||
cat > "$SETTINGS_FILE" << EOF
|
||||
{
|
||||
"network": {
|
||||
@@ -231,12 +231,12 @@ if [[ "${FENCE_TEST_NETWORK:-}" == "1" ]]; then
|
||||
}
|
||||
EOF
|
||||
if command_exists curl; then
|
||||
run_test "allowed domain works" "pass" "$FENCE_BIN" -s "$SETTINGS_FILE" -c "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get"
|
||||
run_test "allowed domain works" "pass" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get"
|
||||
else
|
||||
skip_test "allowed domain works" "curl not installed"
|
||||
fi
|
||||
else
|
||||
skip_test "allowed domain works" "FENCE_TEST_NETWORK not set"
|
||||
skip_test "allowed domain works" "GREYWALL_TEST_NETWORK not set"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -244,25 +244,25 @@ echo "=== Tool Compatibility ==="
|
||||
echo ""
|
||||
|
||||
if command_exists python3; then
|
||||
run_test "python3 works" "pass" "$FENCE_BIN" -c "python3 -c 'print(1+1)'"
|
||||
run_test "python3 works" "pass" "$GREYWALL_BIN" -c "python3 -c 'print(1+1)'"
|
||||
else
|
||||
skip_test "python3 works" "python3 not installed"
|
||||
fi
|
||||
|
||||
if command_exists node; then
|
||||
run_test "node works" "pass" "$FENCE_BIN" -c "node -e 'console.log(1+1)'"
|
||||
run_test "node works" "pass" "$GREYWALL_BIN" -c "node -e 'console.log(1+1)'"
|
||||
else
|
||||
skip_test "node works" "node not installed"
|
||||
fi
|
||||
|
||||
if command_exists git; then
|
||||
run_test "git version works" "pass" "$FENCE_BIN" -- git --version
|
||||
run_test "git version works" "pass" "$GREYWALL_BIN" -- git --version
|
||||
else
|
||||
skip_test "git version works" "git not installed"
|
||||
fi
|
||||
|
||||
if command_exists rg; then
|
||||
run_test "ripgrep works" "pass" "$FENCE_BIN" -- rg --version
|
||||
run_test "ripgrep works" "pass" "$GREYWALL_BIN" -- rg --version
|
||||
else
|
||||
skip_test "ripgrep works" "rg not installed"
|
||||
fi
|
||||
@@ -271,8 +271,8 @@ echo ""
|
||||
echo "=== Environment ==="
|
||||
echo ""
|
||||
|
||||
# Test: FENCE_SANDBOX env var is set
|
||||
run_test "FENCE_SANDBOX set" "pass" "$FENCE_BIN" -c 'test "$FENCE_SANDBOX" = "1"'
|
||||
# Test: GREYWALL_SANDBOX env var is set
|
||||
run_test "GREYWALL_SANDBOX set" "pass" "$GREYWALL_BIN" -c 'test "$GREYWALL_SANDBOX" = "1"'
|
||||
|
||||
# Test: Proxy env vars are set when network is configured
|
||||
cat > "$SETTINGS_FILE" << EOF
|
||||
@@ -286,7 +286,7 @@ cat > "$SETTINGS_FILE" << EOF
|
||||
}
|
||||
EOF
|
||||
|
||||
run_test "HTTP_PROXY set" "pass" "$FENCE_BIN" -s "$SETTINGS_FILE" -c 'test -n "$HTTP_PROXY"'
|
||||
run_test "HTTP_PROXY set" "pass" "$GREYWALL_BIN" -s "$SETTINGS_FILE" -c 'test -n "$HTTP_PROXY"'
|
||||
|
||||
echo ""
|
||||
echo "=============================================="
|
||||
|
||||
Reference in New Issue
Block a user