15 Commits

Author SHA1 Message Date
f4a5c98328 feat: add greywall check and greywall setup commands
Some checks failed
Build and test / Lint (push) Failing after 1m12s
Build and test / Build (push) Successful in 20s
Build and test / Test (Linux) (push) Failing after 1m4s
Add diagnostic and setup commands so users can verify their environment
and install greyproxy without leaving greywall:

- `greywall check`: shows version, platform deps, security features,
  and greyproxy installation/running status (absorbs old --version output)
- `greywall setup`: downloads greyproxy from GitHub releases and shells
  out to `greyproxy install`, or auto-starts if already installed
- `--version` simplified to single-line output for scripting

New `internal/proxy/` package handles greyproxy detection (LookPath +
/api/health endpoint), GitHub release fetching, tar.gz extraction,
and service lifecycle management.
2026-03-04 08:37:49 -06:00
5145016c4e fix: default proxy/DNS to GreyProxy ports (43052/43053)
Some checks failed
Build and test / Build (push) Successful in 25s
Build and test / Lint (push) Failing after 1m9s
Build and test / Test (Linux) (push) Failing after 40s
Switch from internal GreyHaven ports (42052/42053) to GreyProxy defaults
so users no longer need to pass --proxy and --dns flags manually.
2026-03-04 07:32:39 -06:00
62bf37d481 fix: bind-mount greywall binary for Landlock wrapper re-execution
Some checks failed
Build and test / Build (push) Successful in 16s
Build and test / Lint (push) Failing after 1m19s
Build and test / Test (Linux) (push) Failing after 42s
The Landlock wrapper re-executes the greywall binary inside the sandbox
with --landlock-apply. When greywall is run from a path outside the CWD
(e.g., ~/bin/greywall from /home/user/project), the binary doesn't exist
inside the sandbox because only system paths and CWD are mounted. This
adds a --ro-bind for the greywall executable so the wrapper always works
regardless of where the binary is located.
2026-02-22 16:56:45 -06:00
ed6517cc24 fix: make xdg_runtime_dir writable for desktop application
Some checks failed
Build and test / Lint (push) Failing after 1m7s
Build and test / Test (Linux) (push) Failing after 29s
Build and test / Build (push) Successful in 14s
2026-02-22 12:04:01 -06:00
2061dfe63b docs: rewrite README to reflect current architecture
Remove stale references from the pre-GreyHaven era: `-t code` template
flag, `import --claude` subcommand, `allowedDomains` config, `bpftrace`
dependency, and HTTP 403 error messaging.

Update to reflect current features: tun2socks transparent proxying,
learning mode with strace-based template generation, port forwarding,
deny-by-default filesystem reads, environment hardening, shell
completions, and GreyHaven proxy/DNS defaults.
2026-02-17 07:15:02 -06:00
5aeb9c86c0 fix: resolve all golangci-lint v2 warnings (29 issues)
Some checks failed
Build and test / Build (push) Successful in 11s
Build and test / Lint (push) Failing after 1m15s
Build and test / Test (Linux) (push) Failing after 42s
Migrate to golangci-lint v2 config format and fix all lint issues:
- errcheck: add explicit error handling for Close/Remove calls
- gocritic: convert if-else chains to switch statements
- gosec: tighten file permissions, add nolint for intentional cases
- staticcheck: lowercase error strings, simplify boolean returns

Also update Makefile to install golangci-lint v2 and update CLAUDE.md.
2026-02-13 19:20:40 -06:00
626eaa1895 fix: upgrade golangci
Some checks failed
Build and test / Build (push) Successful in 12s
Build and test / Lint (push) Failing after 1m15s
Build and test / Test (Linux) (push) Failing after 40s
2026-02-13 19:13:37 -06:00
18c18ec3a8 fix: avoid creating directory at file path in allowRead bwrap mounts
Some checks failed
Build and test / Build (push) Successful in 12s
Build and test / Lint (push) Failing after 1m17s
Build and test / Test (Linux) (push) Failing after 44s
intermediaryDirs() was called with the full path including the leaf
component, causing --dir to be emitted for files like ~/.npmrc. This
created a directory at that path, making the subsequent --ro-bind fail
with "Can't create file at ...: Is a directory".

Now checks isDirectory() and uses filepath.Dir() for file paths so
intermediary dirs are only created up to the parent.
2026-02-13 13:53:19 -06:00
f4c9422f77 feat: migrate CI and releases from GitHub Actions to Gitea Actions
Retarget GoReleaser to publish to Gitea (gitea_urls, release.gitea,
changelog.use: gitea). Add Gitea Actions workflows for build/test,
release, and benchmarks — adapted from GitHub equivalents with macOS
jobs and SLSA provenance dropped. Old .github/workflows/ kept in place.
2026-02-13 12:20:32 -06:00
c19370f8b3 feat: deny-by-default filesystem isolation
Some checks failed
Build and test / Lint (push) Failing after 1m16s
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 41s
Build and test / Test (macOS) (push) Has been cancelled
- Deny-by-default filesystem isolation for Linux (Landlock) and macOS (Seatbelt)
- Prevent learning mode from collapsing read paths to $HOME
- Add Linux deny-by-default lessons to experience docs
2026-02-13 11:39:18 -06:00
b55b3364af feat: add dependency status to --version and document AppArmor userns fix
Some checks failed
Build and test / Build (push) Successful in 11s
Build and test / Lint (push) Failing after 1m24s
Build and test / Test (Linux) (push) Failing after 40s
Build and test / Test (macOS) (push) Has been cancelled
Show installed dependencies, security features, and transparent proxy
availability when running --version. Detect AppArmor
unprivileged_userns restriction on Ubuntu 24.04+ and suggest the fix.
Document the RTM_NEWADDR issue in experience.md.
2026-02-11 19:31:24 -06:00
70d0685c97 fix: use UDP instead of TCP for DNS bridge to host DNS server
The DnsBridge socat relay was forwarding queries via TCP, but the
GreyHaven DNS service (gost) only listens on UDP, causing DNS
resolution failures ("Could not resolve host") inside the sandbox.
2026-02-11 19:30:56 -06:00
a470f86ee4 fix: resolve ENXIO error and skip template on failed learning runs
Some checks failed
Build and test / Build (push) Successful in 12s
Build and test / Test (macOS) (push) Has been cancelled
Build and test / Lint (push) Failing after 1m23s
Build and test / Test (Linux) (push) Failing after 46s
Skip --new-session in learning mode so interactive programs can access
/dev/tty, and run strace in the foreground to preserve terminal stdin.
Also skip template generation when the traced command exits non-zero,
since the strace trace would be incomplete.
2026-02-11 18:38:26 -06:00
7e85083c38 feat: default to GreyHaven proxy and DNS infrastructure
Default proxy to socks5://localhost:42052 and DNS to localhost:42053
when neither CLI flags nor config file specify them. This makes greywall
work out of the box with GreyHaven without requiring --proxy or --dns.

Also show both proxy and DNS in debug output on manager initialization.
2026-02-11 18:16:35 -06:00
267c82f4bd feat: default DNS to localhost:5353 when proxy is configured
When a proxy is set but no --dns flag or config dnsAddr is specified,
automatically use localhost:5353 as the DNS bridge target. This ensures
DNS queries go through GreyHaven's controlled infrastructure rather than
leaking to public resolvers via tun2socks.

Also update proxy credential injection to always set credentials
(defaulting to "proxy:proxy" when no command name is available), as
required by gost's auth flow.
2026-02-11 18:07:58 -06:00
30 changed files with 1797 additions and 268 deletions

View File

@@ -0,0 +1,101 @@
name: Benchmarks
on:
workflow_dispatch:
inputs:
min_runs:
description: "Minimum benchmark runs"
required: false
default: "30"
quick:
description: "Quick mode (fewer runs)"
required: false
default: "false"
type: boolean
jobs:
benchmark-linux:
name: Benchmark (Linux)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Download dependencies
run: go mod download
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
bubblewrap \
socat \
uidmap \
curl \
netcat-openbsd \
ripgrep \
hyperfine \
jq \
bc
# Configure subuid/subgid
echo "$(whoami):100000:65536" | sudo tee -a /etc/subuid
echo "$(whoami):100000:65536" | sudo tee -a /etc/subgid
sudo chmod u+s $(which bwrap)
- name: Install benchstat
run: go install golang.org/x/perf/cmd/benchstat@latest
- name: Build greywall
run: make build-ci
- name: Run Go microbenchmarks
run: |
mkdir -p benchmarks
go test -run=^$ -bench=. -benchmem -count=10 ./internal/sandbox/... | tee benchmarks/go-bench-linux.txt
- name: Run CLI benchmarks
run: |
MIN_RUNS="${{ github.event.inputs.min_runs || '30' }}"
QUICK="${{ github.event.inputs.quick || 'false' }}"
if [[ "$QUICK" == "true" ]]; then
./scripts/benchmark.sh -q -o benchmarks
else
./scripts/benchmark.sh -n "$MIN_RUNS" -o benchmarks
fi
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results-linux
path: benchmarks/
retention-days: 30
- name: Display results
run: |
echo "=== Linux Benchmark Results ==="
echo ""
for f in benchmarks/*.md; do
[[ -f "$f" ]] && cat "$f"
done
echo ""
echo "=== Go Microbenchmarks ==="
grep -E '^Benchmark|^ok|^PASS' benchmarks/go-bench-linux.txt | head -50 || true

115
.gitea/workflows/main.yml Normal file
View File

@@ -0,0 +1,115 @@
name: Build and test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download dependencies
run: go mod download
- name: Build
run: make build-ci
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Download dependencies
run: go mod download
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
install-mode: goinstall
version: v1.64.8
test-linux:
name: Test (Linux)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Download dependencies
run: go mod download
- name: Install Linux sandbox dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
bubblewrap \
socat \
uidmap \
curl \
netcat-openbsd \
ripgrep
# Configure subuid/subgid for the runner user (required for unprivileged user namespaces)
echo "$(whoami):100000:65536" | sudo tee -a /etc/subuid
echo "$(whoami):100000:65536" | sudo tee -a /etc/subgid
# Make bwrap setuid so it can create namespaces as non-root user
sudo chmod u+s $(which bwrap)
- name: Verify sandbox dependencies
run: |
echo "=== Checking sandbox dependencies ==="
bwrap --version
socat -V | head -1
echo "User namespaces enabled: $(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null || echo 'check not available')"
echo "Kernel version: $(uname -r)"
echo "uidmap installed: $(which newuidmap 2>/dev/null && echo yes || echo no)"
echo "subuid configured: $(grep $(whoami) /etc/subuid 2>/dev/null || echo 'not configured')"
echo "bwrap setuid: $(ls -la $(which bwrap) | grep -q '^-rws' && echo yes || echo no)"
echo "=== Testing bwrap basic functionality ==="
bwrap --ro-bind / / -- /bin/echo "bwrap works!"
echo "=== Testing bwrap with user namespace ==="
bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 -- /bin/echo "bwrap user namespace works!"
- name: Run unit and integration tests
run: make test-ci
- name: Build binary for smoke tests
run: make build-ci
- name: Run smoke tests
run: GREYWALL_TEST_NETWORK=1 ./scripts/smoke_test.sh ./greywall

View File

@@ -0,0 +1,62 @@
name: Release
on:
push:
tags:
- "v*"
run-name: "Release ${{ github.ref_name }}"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GORELEASER_FORCE_TOKEN: gitea
publish-version:
needs: [goreleaser]
runs-on: ubuntu-latest
steps:
- name: Checkout gh-pages
uses: actions/checkout@v4
with:
ref: gh-pages
- name: Update latest version
run: |
echo "${{ github.ref_name }}" > latest.txt
cat > latest.json << EOF
{
"version": "${{ github.ref_name }}",
"published_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"url": "https://gitea.app.monadical.io/monadical/greywall/releases/tag/${{ github.ref_name }}"
}
EOF
- name: Commit and push to gh-pages
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.gitea.app.monadical.io"
git add latest.txt latest.json
git commit -m "Update latest version to ${{ github.ref_name }}" || echo "No changes to commit"
git push origin gh-pages

View File

@@ -1,40 +1,49 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters-settings:
gci:
sections:
- standard
- default
- prefix(gitea.app.monadical.io/monadical/greywall)
gofmt:
simplify: true
goimports:
local-prefixes: gitea.app.monadical.io/monadical/greywall
gocritic:
disabled-checks:
- singleCaseSwitch
revive:
rules:
- name: exported
disabled: true
linters:
enable-all: false
disable-all: true
default: none
enable:
- staticcheck
- errcheck
- gosimple
- govet
- unused
- ineffassign
- gosec
- gocritic
- revive
- gofumpt
- gosec
- govet
- ineffassign
- misspell
issues:
exclude-use-default: false
- revive
- staticcheck
- unused
settings:
gocritic:
disabled-checks:
- singleCaseSwitch
revive:
rules:
- name: exported
disabled: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofumpt
settings:
gci:
sections:
- standard
- default
- prefix(gitea.app.monadical.io/monadical/greywall)
gofmt:
simplify: true
goimports:
local-prefixes:
- gitea.app.monadical.io/monadical/greywall
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1,5 +1,10 @@
version: 2
gitea_urls:
api: https://gitea.app.monadical.io/api/v1
download: https://gitea.app.monadical.io
skip_tls_verify: false
before:
hooks:
- go mod tidy
@@ -42,7 +47,7 @@ checksum:
changelog:
sort: asc
use: github
use: gitea
format: "{{ .SHA }}: {{ .Message }}{{ with .AuthorUsername }} (@{{ . }}){{ end }}"
filters:
exclude:
@@ -76,7 +81,7 @@ changelog:
order: 9999
release:
github:
gitea:
owner: monadical
name: greywall
draft: false

View File

@@ -54,7 +54,7 @@ scripts/ Smoke tests, benchmarks, release
- **Language:** Go 1.25+
- **Formatter:** `gofumpt` (enforced in CI)
- **Linter:** `golangci-lint` v1.64.8 (config in `.golangci.yml`)
- **Linter:** `golangci-lint` v2 (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`)

View File

@@ -70,7 +70,7 @@ build-darwin:
install-lint-tools:
@echo "Installing linting tools..."
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
@echo "Linting tools installed"
setup: deps install-lint-tools

124
README.md
View File

@@ -2,20 +2,20 @@
**The sandboxing layer of the GreyHaven platform.**
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.
Greywall wraps commands in a sandbox that blocks network access by default and restricts filesystem operations. On Linux, it uses tun2socks for truly transparent proxying: all TCP/UDP traffic is captured at the kernel level via a TUN device and forwarded through an external SOCKS5 proxy. No application awareness needed.
```bash
# Block all network access (default)
greywall curl https://example.com # → 403 Forbidden
# Block all network access (default — no proxy running = no connectivity)
greywall -- curl https://example.com
# Allow specific domains
greywall -t code npm install # → uses 'code' template with npm/pypi/etc allowed
# Route traffic through an external SOCKS5 proxy
greywall --proxy socks5://localhost:1080 -- curl https://example.com
# Block dangerous commands
greywall -c "rm -rf /" # → blocked by command deny rules
```
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.
Greywall also works as a permission manager for CLI agents. See [agents.md](./docs/agents.md) for integration with Claude Code, Codex, Gemini CLI, OpenCode, and others.
## Install
@@ -39,83 +39,123 @@ go install gitea.app.monadical.io/monadical/greywall/cmd/greywall@latest
```bash
git clone https://gitea.app.monadical.io/monadical/greywall
cd greywall
go build -o greywall ./cmd/greywall
make setup && make build
```
</details>
**Additional requirements for Linux:**
**Linux dependencies:**
- `bubblewrap` (for sandboxing)
- `socat` (for network bridging)
- `bpftrace` (optional, for filesystem violation visibility when monitoring with `-m`)
- `bubblewrap` — container-free sandboxing (required)
- `socat` network bridging (required)
Check dependency status with `greywall --version`.
## Usage
### Basic
### Basic commands
```bash
# Run command with all network blocked (no domains allowed by default)
greywall curl https://example.com
# Run with all network blocked (default)
greywall -- curl https://example.com
# Run with shell expansion
greywall -c "echo hello && ls"
# Route through a SOCKS5 proxy
greywall --proxy socks5://localhost:1080 -- npm install
# Expose a port for inbound connections (e.g., dev servers)
greywall -p 3000 -c "npm run dev"
# Enable debug logging
greywall -d curl https://example.com
greywall -d -- curl https://example.com
# Use a template
greywall -t code -- claude # Runs Claude Code using `code` template config
# Monitor sandbox violations
greywall -m -- npm install
# Monitor mode (shows violations)
greywall -m npm install
# Show available Linux security features
greywall --linux-features
# Show all commands and options
greywall --help
# Show version and dependency status
greywall --version
```
### Learning mode
Greywall can trace a command's filesystem access and generate a config template automatically:
```bash
# Run in learning mode — traces file access via strace
greywall --learning -- opencode
# List generated templates
greywall templates list
# Show a template's content
greywall templates show opencode
# Next run auto-loads the learned template
greywall -- opencode
```
### Configuration
Greywall reads from `~/.config/greywall/greywall.json` by default (or `~/Library/Application Support/greywall/greywall.json` on macOS).
```json
```jsonc
{
"extends": "code",
"network": { "allowedDomains": ["private.company.com"] },
"filesystem": { "allowWrite": ["."] },
"command": { "deny": ["git push", "npm publish"] }
// Route traffic through an external SOCKS5 proxy
"network": {
"proxyUrl": "socks5://localhost:1080",
"dnsAddr": "localhost:5353"
},
// Control filesystem access
"filesystem": {
"defaultDenyRead": true,
"allowRead": ["~/.config/myapp"],
"allowWrite": ["."],
"denyWrite": ["~/.ssh/**"],
"denyRead": ["~/.ssh/id_*", ".env"]
},
// Block dangerous commands
"command": {
"deny": ["git push", "npm publish"]
}
}
```
Use `greywall --settings ./custom.json` to specify a different config.
Use `greywall --settings ./custom.json` to specify a different config file.
### Import from Claude Code
```bash
greywall import --claude --save
```
By default, traffic routes through the GreyProxy SOCKS5 proxy at `localhost:43052` with DNS via `localhost:43053`.
## Features
- **Network isolation** - All outbound blocked by default; allowlist domains via config
- **Filesystem restrictions** - Control read/write access paths
- **Command blocking** - Deny dangerous commands like `rm -rf /`, `git push`
- **SSH Command Filtering** - Control which hosts and commands are allowed over SSH
- **Built-in templates** - Pre-configured rulesets for common workflows
- **Violation monitoring** - Real-time logging of blocked requests (`-m`)
- **Cross-platform** - macOS (sandbox-exec) + Linux (bubblewrap)
- **Transparent proxy** All TCP/UDP traffic captured at the kernel level via tun2socks and routed through an external SOCKS5 proxy (Linux)
- **Network isolation** — All outbound blocked by default; traffic only flows when a proxy is available
- **Filesystem restrictions** Deny-by-default read mode, controlled write paths, sensitive file protection
- **Learning mode** — Trace filesystem access with strace and auto-generate config templates
- **Command blocking** — Deny dangerous commands (`rm -rf /`, `git push`, `shutdown`, etc.)
- **SSH filtering** — Control which hosts and commands are allowed over SSH
- **Environment hardening** — Strips dangerous env vars (`LD_PRELOAD`, `DYLD_*`, etc.)
- **Violation monitoring** — Real-time logging of sandbox violations (`-m`)
- **Shell completions** — `greywall completion bash|zsh|fish|powershell`
- **Cross-platform** — Linux (bubblewrap + seccomp + Landlock + eBPF) and macOS (sandbox-exec)
Greywall can be used as a Go package or CLI tool.
Greywall can also be used as a [Go package](docs/library.md).
## Documentation
- [Index](/docs/README.md)
- [Documentation Index](docs/README.md)
- [Quickstart Guide](docs/quickstart.md)
- [Why Greywall](docs/why-greywall.md)
- [Configuration Reference](docs/configuration.md)
- [Security Model](docs/security-model.md)
- [Architecture](ARCHITECTURE.md)
- [Linux Security Features](docs/linux-security-features.md)
- [AI Agent Integration](docs/agents.md)
- [Library Usage (Go)](docs/library.md)
- [Examples](examples/)
- [Troubleshooting](docs/troubleshooting.md)
## Attribution

View File

@@ -15,6 +15,7 @@ import (
"gitea.app.monadical.io/monadical/greywall/internal/config"
"gitea.app.monadical.io/monadical/greywall/internal/platform"
"gitea.app.monadical.io/monadical/greywall/internal/proxy"
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
"github.com/spf13/cobra"
)
@@ -55,9 +56,9 @@ func main() {
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).
By default, traffic is routed through the GreyProxy SOCKS5 proxy at localhost:43052
with DNS via localhost:43053. Use --proxy and --dns to override, or configure 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
@@ -98,8 +99,8 @@ Configuration file format:
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().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:43052)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:43053)")
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")
@@ -111,6 +112,8 @@ Configuration file format:
rootCmd.AddCommand(newCompletionCmd(rootCmd))
rootCmd.AddCommand(newTemplatesCmd())
rootCmd.AddCommand(newCheckCmd())
rootCmd.AddCommand(newSetupCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -121,10 +124,7 @@ Configuration file format:
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)
fmt.Printf("greywall %s\n", version)
return nil
}
@@ -202,19 +202,20 @@ func runCommand(cmd *cobra.Command, args []string) error {
if templatePath != "" {
learnedCfg, loadErr := config.Load(templatePath)
if loadErr != nil {
switch {
case loadErr != nil:
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template: %v\n", loadErr)
}
} else if learnedCfg != nil {
case learnedCfg != nil:
cfg = config.Merge(cfg, learnedCfg)
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel)
}
} else if templateName != "" {
case templateName != "":
// Explicit --template but file doesn't exist
return fmt.Errorf("learned template %q not found at %s\nRun: greywall templates list", templateName, templatePath)
} else if cmdName != "" {
case cmdName != "":
// No template found for this command - suggest creating one
fmt.Fprintf(os.Stderr, "[greywall] No learned template for %q. Run with --learning to create one.\n", cmdName)
}
@@ -229,14 +230,35 @@ func runCommand(cmd *cobra.Command, args []string) error {
cfg.Network.DnsAddr = dnsAddr
}
// Auto-inject command name as SOCKS5 proxy username when no credentials are set.
// This lets the proxy identify which sandboxed command originated the traffic.
if cfg.Network.ProxyURL != "" && cmdName != "" {
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil && u.User == nil {
u.User = url.User(cmdName)
// GreyProxy defaults: when no proxy or DNS is configured (neither via CLI
// nor config file), use the standard GreyProxy ports.
if cfg.Network.ProxyURL == "" {
cfg.Network.ProxyURL = "socks5://localhost:43052"
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:43052\n")
}
}
if cfg.Network.DnsAddr == "" {
cfg.Network.DnsAddr = "localhost:43053"
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Defaulting DNS to localhost:43053\n")
}
}
// Auto-inject proxy credentials so the proxy can identify the sandboxed command.
// - If a command name is available, use it as the username with "proxy" as password.
// - If no command name, default to "proxy:proxy" (required by gost for auth).
// This always overrides any existing credentials in the URL.
if cfg.Network.ProxyURL != "" {
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
proxyUser := "proxy"
if cmdName != "" {
proxyUser = cmdName
}
u.User = url.UserPassword(proxyUser, "proxy")
cfg.Network.ProxyURL = u.String()
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Auto-set proxy username to %q\n", cmdName)
fmt.Fprintf(os.Stderr, "[greywall] Auto-set proxy credentials to %q:proxy\n", proxyUser)
}
}
}
@@ -334,25 +356,32 @@ func runCommand(cmd *cobra.Command, args []string) error {
}()
// Wait for command to finish
commandFailed := false
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()
// Continue to template generation even if command exited non-zero
commandFailed = true
} else {
return fmt.Errorf("command failed: %w", err)
}
}
// Generate learned template after command completes
// Generate learned template after command completes successfully.
// Skip template generation if the command failed — the strace trace
// is likely incomplete and would produce an unreliable template.
if learning && manager.IsLearning() {
fmt.Fprintf(os.Stderr, "[greywall] Analyzing filesystem access patterns...\n")
templatePath, genErr := manager.GenerateLearnedTemplate(cmdName)
if genErr != nil {
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to generate template: %v\n", genErr)
if commandFailed {
fmt.Fprintf(os.Stderr, "[greywall] Skipping template generation: command exited with code %d\n", exitCode)
} else {
fmt.Fprintf(os.Stderr, "[greywall] Template saved to: %s\n", templatePath)
fmt.Fprintf(os.Stderr, "[greywall] Next run will auto-load this template.\n")
fmt.Fprintf(os.Stderr, "[greywall] Analyzing filesystem access patterns...\n")
templatePath, genErr := manager.GenerateLearnedTemplate(cmdName)
if genErr != nil {
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to generate template: %v\n", genErr)
} else {
fmt.Fprintf(os.Stderr, "[greywall] Template saved to: %s\n", templatePath)
fmt.Fprintf(os.Stderr, "[greywall] Next run will auto-load this template.\n")
}
}
}
@@ -382,6 +411,89 @@ func extractCommandName(args []string, cmdStr string) string {
return filepath.Base(name)
}
// newCheckCmd creates the check subcommand for diagnostics.
func newCheckCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Check greywall status, dependencies, and greyproxy connectivity",
Long: `Run diagnostics to check greywall readiness.
Shows version information, platform dependencies, security features,
and greyproxy installation/running status.`,
Args: cobra.NoArgs,
RunE: runCheck,
}
}
func runCheck(_ *cobra.Command, _ []string) error {
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)
sandbox.PrintDependencyStatus()
fmt.Printf("\n Greyproxy:\n")
status := proxy.Detect()
if status.Installed {
if status.Version != "" {
fmt.Printf(" ✓ installed (v%s) at %s\n", status.Version, status.Path)
} else {
fmt.Printf(" ✓ installed at %s\n", status.Path)
}
if status.Running {
fmt.Printf(" ✓ running (SOCKS5 :43052, DNS :43053, Dashboard :43080)\n")
} else {
fmt.Printf(" ✗ not running\n")
fmt.Printf(" Start with: greywall setup\n")
}
} else {
fmt.Printf(" ✗ not installed\n")
fmt.Printf(" Install with: greywall setup\n")
}
return nil
}
// newSetupCmd creates the setup subcommand for installing greyproxy.
func newSetupCmd() *cobra.Command {
return &cobra.Command{
Use: "setup",
Short: "Install and start greyproxy (network proxy for sandboxed commands)",
Long: `Downloads and installs greyproxy from GitHub releases.
greyproxy provides SOCKS5 proxying and DNS resolution for sandboxed commands.
The installer will:
1. Download the latest greyproxy release for your platform
2. Install the binary to ~/.local/bin/greyproxy
3. Register and start a systemd user service`,
Args: cobra.NoArgs,
RunE: runSetup,
}
}
func runSetup(_ *cobra.Command, _ []string) error {
status := proxy.Detect()
if status.Installed && status.Running {
fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version)
fmt.Printf("Run 'greywall check' for full status.\n")
return nil
}
if status.Installed && !status.Running {
if err := proxy.Start(os.Stderr); err != nil {
return err
}
fmt.Printf("greyproxy started.\n")
return nil
}
return proxy.Install(proxy.InstallOptions{
Output: os.Stderr,
})
}
// newCompletionCmd creates the completion subcommand for shell completions.
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
cmd := &cobra.Command{
@@ -474,7 +586,7 @@ Examples:
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
templatePath := sandbox.LearnedTemplatePath(name)
data, err := os.ReadFile(templatePath)
data, err := os.ReadFile(templatePath) //nolint:gosec // user-specified template path - intentional
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("template %q not found\nRun: greywall templates list", name)

View File

@@ -67,3 +67,55 @@ Lessons learned and issues encountered during development.
**Problem:** gost's SOCKS5 server always selects authentication method 0x02 (username/password), even when no real credentials are needed. Clients that only offer method 0x00 (no auth) get rejected.
**Fix:** Always include credentials in the proxy URL (e.g., `proxy:proxy@`). In tun2socks proxy URL construction, include `userinfo` so tun2socks offers both auth methods during SOCKS5 negotiation.
---
## Network namespaces fail on Ubuntu 24.04 (`RTM_NEWADDR: Operation not permitted`)
**Problem:** On Ubuntu 24.04 (tested in a KVM guest with bridged virtio/virbr0), `--version` reports `bwrap(no-netns)` and transparent proxy is unavailable. `kernel.unprivileged_userns_clone=1` is set, bwrap and socat are installed, but `bwrap --unshare-net` fails with:
```
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
```
**Cause:** Ubuntu 24.04 introduced `kernel.apparmor_restrict_unprivileged_userns` (default: 1). This strips capabilities like `CAP_NET_ADMIN` from processes inside unprivileged user namespaces, even without a bwrap-specific AppArmor profile. Bubblewrap creates the network namespace successfully but cannot configure the loopback interface (adding 127.0.0.1 via netlink RTM_NEWADDR requires `CAP_NET_ADMIN`). Not a hypervisor issue — happens on bare metal Ubuntu 24.04 too.
**Diagnosis:**
```bash
sysctl kernel.apparmor_restrict_unprivileged_userns # likely returns 1
bwrap --unshare-net --ro-bind / / -- /bin/true # reproduces the error
```
**Fix:** Disable the restriction (requires root on the guest):
```bash
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Persist across reboots:
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-greywall-userns.conf
```
**Alternative:** Accept the limitation — greywall still works for filesystem sandboxing, seccomp, and Landlock. Network access is blocked outright rather than redirected through a proxy.
---
## Linux: symlinked system dirs invisible after `--tmpfs /`
**Problem:** On merged-usr distros (Arch, Fedora, modern Ubuntu), `/bin`, `/sbin`, `/lib`, `/lib64` are symlinks (e.g., `/bin -> usr/bin`). When switching from `--ro-bind / /` to `--tmpfs /` for deny-by-default isolation, these symlinks don't exist in the empty root. The `canMountOver()` helper explicitly rejects symlinks, so `--ro-bind /bin /bin` was silently skipped. Result: `execvp /usr/bin/bash: No such file or directory` — bash exists at `/usr/bin/bash` but the dynamic linker at `/lib64/ld-linux-x86-64.so.2` can't be found because `/lib64` is missing.
**Diagnosis:** The error message is misleading. `execvp` reports "No such file or directory" both when the binary is missing and when the ELF interpreter (dynamic linker) is missing. The actual binary `/usr/bin/bash` existed via the `/usr` bind-mount, but the symlink `/lib64 -> usr/lib` was gone.
**Fix:** Check each system path with `isSymlink()` before mounting. Symlinks get `--symlink <target> <path>` (bwrap recreates the symlink inside the sandbox); real directories get `--ro-bind`. On Arch: `--symlink usr/bin /bin`, `--symlink usr/bin /sbin`, `--symlink usr/lib /lib`, `--symlink usr/lib /lib64`.
---
## Linux: Landlock denies reads on bind-mounted /dev/null
**Problem:** To mask `.env` files inside CWD, the initial approach used `--ro-bind /dev/null <cwd>/.env`. Inside the sandbox, `.env` appeared as a character device (bind mounts preserve file type). Landlock's `LANDLOCK_ACCESS_FS_READ_FILE` right only covers regular files, not character devices. Result: `cat .env` returned "Permission denied" instead of empty content.
**Fix:** Use an empty regular file (`/tmp/greywall/empty`, 0 bytes, mode 0444) as the mask source instead of `/dev/null`. Landlock sees a regular file and allows the read. The file is created once in a fixed location under the greywall temp dir.
---
## Linux: mandatory deny paths override sensitive file masks
**Problem:** In deny-by-default mode, `buildDenyByDefaultMounts()` correctly masked `.env` with `--ro-bind /tmp/greywall/empty <cwd>/.env`. But later in `WrapCommandLinuxWithOptions()`, the mandatory deny paths section called `getMandatoryDenyPaths()` which included `.env` files (added for write protection). It then applied `--ro-bind <cwd>/.env <cwd>/.env`, binding the real file over the empty mask. bwrap applies mounts in order, so the later ro-bind undid the masking.
**Fix:** Track paths already masked by `buildDenyByDefaultMounts()` in a set. Skip those paths in the mandatory deny section to preserve the empty-file overlay.

View File

@@ -36,7 +36,7 @@ type NetworkConfig struct {
// FilesystemConfig defines filesystem restrictions.
type FilesystemConfig struct {
DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead
DefaultDenyRead *bool `json:"defaultDenyRead,omitempty"` // If nil or true, deny reads by default except system paths, CWD, and AllowRead
AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
DenyRead []string `json:"denyRead"`
AllowWrite []string `json:"allowWrite"`
@@ -44,6 +44,12 @@ type FilesystemConfig struct {
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
}
// IsDefaultDenyRead returns whether deny-by-default read mode is enabled.
// Defaults to true when not explicitly set (nil).
func (f *FilesystemConfig) IsDefaultDenyRead() bool {
return f.DefaultDenyRead == nil || *f.DefaultDenyRead
}
// CommandConfig defines command restrictions.
type CommandConfig struct {
Deny []string `json:"deny"`
@@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config {
},
Filesystem: FilesystemConfig{
// Boolean fields: true if either enables it
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead,
// Pointer field: override wins if set, otherwise base (nil = deny-by-default)
DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead),
// Append slices
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),

View File

@@ -410,7 +410,7 @@ func TestMerge(t *testing.T) {
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: true,
DefaultDenyRead: boolPtr(true),
AllowRead: []string{"/home/user/project"},
},
}
@@ -421,13 +421,40 @@ func TestMerge(t *testing.T) {
}
result := Merge(base, override)
if !result.Filesystem.DefaultDenyRead {
t.Error("expected DefaultDenyRead to be true (from base)")
if !result.Filesystem.IsDefaultDenyRead() {
t.Error("expected IsDefaultDenyRead() to be true (from base)")
}
if len(result.Filesystem.AllowRead) != 2 {
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
}
})
t.Run("defaultDenyRead nil defaults to true", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{},
}
result := Merge(base, nil)
if !result.Filesystem.IsDefaultDenyRead() {
t.Error("expected IsDefaultDenyRead() to be true when nil (deny-by-default)")
}
})
t.Run("defaultDenyRead explicit false overrides", func(t *testing.T) {
base := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: boolPtr(true),
},
}
override := &Config{
Filesystem: FilesystemConfig{
DefaultDenyRead: boolPtr(false),
},
}
result := Merge(base, override)
if result.Filesystem.IsDefaultDenyRead() {
t.Error("expected IsDefaultDenyRead() to be false (override explicit false)")
}
})
}
func boolPtr(b bool) *bool {

126
internal/proxy/detect.go Normal file
View File

@@ -0,0 +1,126 @@
// Package proxy provides greyproxy detection, installation, and lifecycle management.
package proxy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"strings"
"time"
)
const (
healthURL = "http://localhost:43080/api/health"
healthTimeout = 2 * time.Second
cmdTimeout = 5 * time.Second
)
// GreyproxyStatus holds the detected state of greyproxy.
type GreyproxyStatus struct {
Installed bool // found via exec.LookPath
Path string // full path from LookPath
Version string // parsed version (e.g. "0.1.1")
Running bool // health endpoint responded with valid greyproxy response
RunningErr error // error from the running check (for diagnostics)
}
// healthResponse is the expected JSON from GET /api/health.
type healthResponse struct {
Service string `json:"service"`
Version string `json:"version"`
Status string `json:"status"`
Ports map[string]int `json:"ports"`
}
var versionRegex = regexp.MustCompile(`^greyproxy\s+(\S+)`)
// Detect checks greyproxy installation status, version, and whether it's running.
// This function never returns an error; all detection failures are captured
// in the GreyproxyStatus fields so the caller can present them diagnostically.
func Detect() *GreyproxyStatus {
s := &GreyproxyStatus{}
// 1. Check if installed
s.Path, s.Installed = checkInstalled()
// 2. Check if running (via health endpoint)
running, ver, err := checkRunning()
s.Running = running
s.RunningErr = err
if running && ver != "" {
s.Version = ver
}
// 3. Version fallback: if installed but version not yet known, parse from CLI
if s.Installed && s.Version == "" {
s.Version, _ = checkVersion(s.Path)
}
return s
}
// checkInstalled uses exec.LookPath to find greyproxy on PATH.
func checkInstalled() (path string, found bool) {
p, err := exec.LookPath("greyproxy")
if err != nil {
return "", false
}
return p, true
}
// checkVersion runs "greyproxy -V" and parses the output.
// Expected format: "greyproxy 0.1.1 (go1.x linux/amd64)"
func checkVersion(binaryPath string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, binaryPath, "-V").Output() //nolint:gosec // binaryPath comes from exec.LookPath
if err != nil {
return "", fmt.Errorf("failed to run greyproxy -V: %w", err)
}
matches := versionRegex.FindStringSubmatch(strings.TrimSpace(string(out)))
if len(matches) < 2 {
return "", fmt.Errorf("unexpected version output: %s", strings.TrimSpace(string(out)))
}
return matches[1], nil
}
// checkRunning hits GET http://localhost:43080/api/health and verifies
// the response is from greyproxy (not some other service on that port).
// Returns running status, version string from health response, and any error.
func checkRunning() (bool, string, error) {
client := &http.Client{Timeout: healthTimeout}
ctx, cancel := context.WithTimeout(context.Background(), healthTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return false, "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req) //nolint:gosec // healthURL is a hardcoded localhost constant
if err != nil {
return false, "", fmt.Errorf("health check failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return false, "", fmt.Errorf("health check returned status %d", resp.StatusCode)
}
var health healthResponse
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
return false, "", fmt.Errorf("failed to parse health response: %w", err)
}
if health.Service != "greyproxy" {
return false, "", fmt.Errorf("unexpected service: %q (expected greyproxy)", health.Service)
}
return true, health.Version, nil
}

258
internal/proxy/install.go Normal file
View File

@@ -0,0 +1,258 @@
package proxy
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
githubOwner = "greyhavenhq"
githubRepo = "greyproxy"
apiTimeout = 15 * time.Second
)
// release represents a GitHub release.
type release struct {
TagName string `json:"tag_name"`
Assets []asset `json:"assets"`
}
// asset represents a release asset.
type asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// InstallOptions controls the greyproxy installation behavior.
type InstallOptions struct {
Output io.Writer // progress output (typically os.Stderr)
}
// Install downloads the latest greyproxy release and runs "greyproxy install".
func Install(opts InstallOptions) error {
if opts.Output == nil {
opts.Output = os.Stderr
}
// 1. Fetch latest release
_, _ = fmt.Fprintf(opts.Output, "Fetching latest greyproxy release...\n")
rel, err := fetchLatestRelease()
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
ver := strings.TrimPrefix(rel.TagName, "v")
_, _ = fmt.Fprintf(opts.Output, "Latest version: %s\n", ver)
// 2. Find the correct asset for this platform
assetURL, assetName, err := resolveAssetURL(rel)
if err != nil {
return err
}
_, _ = fmt.Fprintf(opts.Output, "Downloading %s...\n", assetName)
// 3. Download to temp file
archivePath, err := downloadAsset(assetURL)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer func() { _ = os.Remove(archivePath) }()
// 4. Extract
_, _ = fmt.Fprintf(opts.Output, "Extracting...\n")
extractDir, err := extractTarGz(archivePath)
if err != nil {
return fmt.Errorf("extraction failed: %w", err)
}
defer func() { _ = os.RemoveAll(extractDir) }()
// 5. Find the greyproxy binary in extracted content
binaryPath := filepath.Join(extractDir, "greyproxy")
if _, err := os.Stat(binaryPath); err != nil {
return fmt.Errorf("greyproxy binary not found in archive")
}
// 6. Shell out to "greyproxy install"
_, _ = fmt.Fprintf(opts.Output, "\n")
if err := runGreyproxyInstall(binaryPath); err != nil {
return fmt.Errorf("greyproxy install failed: %w", err)
}
// 7. Verify
_, _ = fmt.Fprintf(opts.Output, "\nVerifying installation...\n")
status := Detect()
if status.Installed {
_, _ = fmt.Fprintf(opts.Output, "greyproxy %s installed at %s\n", status.Version, status.Path)
if status.Running {
_, _ = fmt.Fprintf(opts.Output, "greyproxy is running.\n")
}
} else {
_, _ = fmt.Fprintf(opts.Output, "Warning: greyproxy not found on PATH after install.\n")
_, _ = fmt.Fprintf(opts.Output, "Ensure ~/.local/bin is in your PATH.\n")
}
return nil
}
// fetchLatestRelease queries the GitHub API for the latest greyproxy release.
func fetchLatestRelease() (*release, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", githubOwner, githubRepo)
client := &http.Client{Timeout: apiTimeout}
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "greywall-setup")
resp, err := client.Do(req) //nolint:gosec // apiURL is built from hardcoded constants
if err != nil {
return nil, fmt.Errorf("GitHub API request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var rel release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("failed to parse release response: %w", err)
}
return &rel, nil
}
// resolveAssetURL finds the correct asset download URL for the current OS/arch.
func resolveAssetURL(rel *release) (downloadURL, name string, err error) {
ver := strings.TrimPrefix(rel.TagName, "v")
osName := runtime.GOOS
archName := runtime.GOARCH
expected := fmt.Sprintf("greyproxy_%s_%s_%s.tar.gz", ver, osName, archName)
for _, a := range rel.Assets {
if a.Name == expected {
return a.BrowserDownloadURL, a.Name, nil
}
}
return "", "", fmt.Errorf("no release asset found for %s/%s (expected: %s)", osName, archName, expected)
}
// downloadAsset downloads a URL to a temp file, returning its path.
func downloadAsset(downloadURL string) (string, error) {
client := &http.Client{Timeout: 5 * time.Minute}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req) //nolint:gosec // downloadURL comes from GitHub API response
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
tmpFile, err := os.CreateTemp("", "greyproxy-*.tar.gz")
if err != nil {
return "", err
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name()) //nolint:gosec // tmpFile.Name() is from os.CreateTemp, not user input
return "", err
}
_ = tmpFile.Close()
return tmpFile.Name(), nil
}
// extractTarGz extracts a .tar.gz archive to a temp directory, returning the dir path.
func extractTarGz(archivePath string) (string, error) {
f, err := os.Open(archivePath) //nolint:gosec // archivePath is a temp file we created
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() { _ = gz.Close() }()
tmpDir, err := os.MkdirTemp("", "greyproxy-extract-*")
if err != nil {
return "", err
}
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
_ = os.RemoveAll(tmpDir)
return "", fmt.Errorf("tar read error: %w", err)
}
// Sanitize: only extract regular files with safe names
name := filepath.Base(header.Name)
if name == "." || name == ".." || strings.Contains(header.Name, "..") {
continue
}
target := filepath.Join(tmpDir, name) //nolint:gosec // name is sanitized via filepath.Base and path traversal check above
switch header.Typeflag {
case tar.TypeReg:
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) //nolint:gosec // mode from tar header of trusted archive
if err != nil {
_ = os.RemoveAll(tmpDir)
return "", err
}
if _, err := io.Copy(out, io.LimitReader(tr, 256<<20)); err != nil { // 256 MB limit per file
_ = out.Close()
_ = os.RemoveAll(tmpDir)
return "", err
}
_ = out.Close()
}
}
return tmpDir, nil
}
// runGreyproxyInstall shells out to the extracted greyproxy binary with "install" arg.
// Stdin/stdout/stderr are passed through so the interactive [y/N] prompt works.
func runGreyproxyInstall(binaryPath string) error {
cmd := exec.Command(binaryPath, "install") //nolint:gosec // binaryPath is from our extracted archive
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

30
internal/proxy/start.go Normal file
View File

@@ -0,0 +1,30 @@
package proxy
import (
"fmt"
"io"
"os"
"os/exec"
)
// Start runs "greyproxy service start" to start the greyproxy service.
func Start(output io.Writer) error {
if output == nil {
output = os.Stderr
}
path, found := checkInstalled()
if !found {
return fmt.Errorf("greyproxy not found on PATH")
}
_, _ = fmt.Fprintf(output, "Starting greyproxy service...\n")
cmd := exec.Command(path, "service", "start") //nolint:gosec // path comes from exec.LookPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to start greyproxy service: %w", err)
}
return nil
}

View File

@@ -28,6 +28,30 @@ var DangerousDirectories = []string{
".claude/agents",
}
// SensitiveProjectFiles lists files within the project directory that should be
// denied for both read and write access. These commonly contain secrets.
var SensitiveProjectFiles = []string{
".env",
".env.local",
".env.development",
".env.production",
".env.staging",
".env.test",
}
// GetSensitiveProjectPaths returns concrete paths for sensitive files within the
// given directory. Only returns paths for files that actually exist.
func GetSensitiveProjectPaths(cwd string) []string {
var paths []string
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
if _, err := os.Stat(p); err == nil {
paths = append(paths, p)
}
}
return paths
}
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
func GetDefaultWritePaths() []string {
home, _ := os.UserHomeDir()

View File

@@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) {
// ============================================================================
// testConfig creates a test configuration with sensible defaults.
// Uses legacy mode (defaultDenyRead=false) for predictable testing of
// existing integration tests. Use testConfigDenyByDefault() for tests
// that specifically test deny-by-default behavior.
func testConfig() *config.Config {
return &config.Config{
Network: config.NetworkConfig{},
Filesystem: config.FilesystemConfig{
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
},
Command: config.CommandConfig{
Deny: []string{},

View File

@@ -87,6 +87,43 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
allowWrite = append(allowWrite, toTildePath(p, home))
}
// Filter read paths: remove system defaults, CWD subtree, and sensitive paths
cwd, _ := os.Getwd()
var filteredReads []string
defaultReadable := GetDefaultReadablePaths()
for _, p := range result.ReadPaths {
// Skip system defaults
isDefault := false
for _, dp := range defaultReadable {
if p == dp || strings.HasPrefix(p, dp+"/") {
isDefault = true
break
}
}
if isDefault {
continue
}
// Skip CWD subtree (auto-included)
if cwd != "" && (p == cwd || strings.HasPrefix(p, cwd+"/")) {
continue
}
// Skip sensitive paths
if isSensitivePath(p, home) {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Skipping sensitive read path: %s\n", p)
}
continue
}
filteredReads = append(filteredReads, p)
}
// Collapse read paths and convert to tilde-relative
collapsedReads := CollapsePaths(filteredReads)
var allowRead []string
for _, p := range collapsedReads {
allowRead = append(allowRead, toTildePath(p, home))
}
// Convert read paths to tilde-relative for display
var readDisplay []string
for _, p := range result.ReadPaths {
@@ -103,6 +140,13 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
}
}
if len(allowRead) > 0 {
fmt.Fprintf(os.Stderr, "[greywall] Additional read paths (beyond system + CWD):\n")
for _, p := range allowRead {
fmt.Fprintf(os.Stderr, "[greywall] %s\n", p)
}
}
if len(allowWrite) > 1 { // >1 because "." is always included
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
for _, p := range allowWrite {
@@ -118,15 +162,15 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
fmt.Fprintf(os.Stderr, "\n")
// Build template
template := buildTemplate(cmdName, allowWrite)
template := buildTemplate(cmdName, allowRead, allowWrite)
// Save template
templatePath := LearnedTemplatePath(cmdName)
if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(templatePath), 0o750); err != nil {
return "", fmt.Errorf("failed to create template directory: %w", err)
}
if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil {
if err := os.WriteFile(templatePath, []byte(template), 0o600); err != nil {
return "", fmt.Errorf("failed to write template: %w", err)
}
@@ -176,9 +220,15 @@ func CollapsePaths(paths []string) []string {
}
}
// For standalone paths, use their parent directory
// For standalone paths, use their parent directory — but never collapse to $HOME
for _, p := range standalone {
result = append(result, filepath.Dir(p))
parent := filepath.Dir(p)
if parent == home {
// Keep exact file path to avoid opening entire home directory
result = append(result, p)
} else {
result = append(result, parent)
}
}
// Sort and deduplicate (remove sub-paths of other paths)
@@ -255,11 +305,7 @@ func isSensitivePath(path, home string) bool {
// Check GPG
gnupgDir := filepath.Join(home, ".gnupg")
if strings.HasPrefix(path, gnupgDir+"/") {
return true
}
return false
return strings.HasPrefix(path, gnupgDir+"/")
}
// getDangerousFilePatterns returns denyWrite entries for DangerousFiles.
@@ -345,9 +391,18 @@ func deduplicateSubPaths(paths []string) []string {
return result
}
// getSensitiveProjectDenyPatterns returns denyRead entries for sensitive project files.
func getSensitiveProjectDenyPatterns() []string {
return []string{
".env",
".env.*",
}
}
// buildTemplate generates the JSONC template content for a learned config.
func buildTemplate(cmdName string, allowWrite []string) string {
func buildTemplate(cmdName string, allowRead, allowWrite []string) string {
type fsConfig struct {
AllowRead []string `json:"allowRead,omitempty"`
AllowWrite []string `json:"allowWrite"`
DenyWrite []string `json:"denyWrite"`
DenyRead []string `json:"denyRead"`
@@ -356,11 +411,15 @@ func buildTemplate(cmdName string, allowWrite []string) string {
Filesystem fsConfig `json:"filesystem"`
}
// Combine sensitive read patterns with .env project patterns
denyRead := append(getSensitiveReadPatterns(), getSensitiveProjectDenyPatterns()...)
cfg := templateConfig{
Filesystem: fsConfig{
AllowRead: allowRead,
AllowWrite: allowWrite,
DenyWrite: getDangerousFilePatterns(),
DenyRead: getSensitiveReadPatterns(),
DenyRead: denyRead,
},
}

View File

@@ -41,7 +41,7 @@ func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
if err != nil {
return nil, fmt.Errorf("failed to open strace log: %w", err)
}
defer f.Close()
defer func() { _ = f.Close() }()
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)

View File

@@ -127,7 +127,7 @@ func TestParseStraceLog(t *testing.T) {
}, "\n")
logFile := filepath.Join(t.TempDir(), "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}

View File

@@ -70,14 +70,13 @@ func TestFindApplicationDirectory(t *testing.T) {
func TestCollapsePaths(t *testing.T) {
// Temporarily override home for testing
origHome := os.Getenv("HOME")
os.Setenv("HOME", "/home/testuser")
defer os.Setenv("HOME", origHome)
t.Setenv("HOME", "/home/testuser")
tests := []struct {
name string
paths []string
contains []string // paths that should be in the result
name string
paths []string
contains []string // paths that should be in the result
notContains []string // paths that must NOT be in the result
}{
{
name: "multiple paths under same app dir",
@@ -111,6 +110,33 @@ func TestCollapsePaths(t *testing.T) {
"/home/testuser/.config/opencode",
},
},
{
name: "files directly under home stay as exact paths",
paths: []string{
"/home/testuser/.gitignore",
"/home/testuser/.npmrc",
},
contains: []string{
"/home/testuser/.gitignore",
"/home/testuser/.npmrc",
},
notContains: []string{"/home/testuser"},
},
{
name: "mix of home files and app dir paths",
paths: []string{
"/home/testuser/.gitignore",
"/home/testuser/.cache/opencode/db/main.sqlite",
"/home/testuser/.cache/opencode/version",
"/home/testuser/.npmrc",
},
contains: []string{
"/home/testuser/.gitignore",
"/home/testuser/.npmrc",
"/home/testuser/.cache/opencode",
},
notContains: []string{"/home/testuser"},
},
}
for _, tt := range tests {
@@ -134,6 +160,13 @@ func TestCollapsePaths(t *testing.T) {
t.Errorf("CollapsePaths() = %v, missing expected path %q", got, want)
}
}
for _, bad := range tt.notContains {
for _, g := range got {
if g == bad {
t.Errorf("CollapsePaths() = %v, should NOT contain %q", got, bad)
}
}
}
})
}
}
@@ -284,9 +317,7 @@ func TestToTildePath(t *testing.T) {
func TestListLearnedTemplates(t *testing.T) {
// Use a temp dir to isolate from real user config
tmpDir := t.TempDir()
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Initially empty
templates, err := ListLearnedTemplates()
@@ -299,10 +330,18 @@ func TestListLearnedTemplates(t *testing.T) {
// Create some templates
dir := LearnedTemplateDir()
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o644)
os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o644)
os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o644) // should be ignored
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o600); err != nil {
t.Fatal(err)
}
templates, err = ListLearnedTemplates()
if err != nil {
@@ -325,8 +364,9 @@ func TestListLearnedTemplates(t *testing.T) {
}
func TestBuildTemplate(t *testing.T) {
allowRead := []string{"~/external-data"}
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
result := buildTemplate("opencode", allowWrite)
result := buildTemplate("opencode", allowRead, allowWrite)
// Check header comments
if !strings.Contains(result, `Learned template for "opencode"`) {
@@ -340,6 +380,12 @@ func TestBuildTemplate(t *testing.T) {
}
// Check content
if !strings.Contains(result, `"allowRead"`) {
t.Error("template missing allowRead field")
}
if !strings.Contains(result, `"~/external-data"`) {
t.Error("template missing expected allowRead path")
}
if !strings.Contains(result, `"allowWrite"`) {
t.Error("template missing allowWrite field")
}
@@ -352,14 +398,28 @@ func TestBuildTemplate(t *testing.T) {
if !strings.Contains(result, `"denyRead"`) {
t.Error("template missing denyRead field")
}
// Check .env patterns are included in denyRead
if !strings.Contains(result, `".env"`) {
t.Error("template missing .env in denyRead")
}
if !strings.Contains(result, `".env.*"`) {
t.Error("template missing .env.* in denyRead")
}
}
func TestBuildTemplateNoAllowRead(t *testing.T) {
result := buildTemplate("simple-cmd", nil, []string{"."})
// When allowRead is nil, it should be omitted from JSON
if strings.Contains(result, `"allowRead"`) {
t.Error("template should omit allowRead when nil")
}
}
func TestGenerateLearnedTemplate(t *testing.T) {
// Create a temp dir for templates
tmpDir := t.TempDir()
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Create a fake strace log
home, _ := os.UserHomeDir()
@@ -372,7 +432,7 @@ func TestGenerateLearnedTemplate(t *testing.T) {
}, "\n")
logFile := filepath.Join(tmpDir, "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
@@ -386,7 +446,7 @@ func TestGenerateLearnedTemplate(t *testing.T) {
}
// Read and verify template
data, err := os.ReadFile(templatePath)
data, err := os.ReadFile(templatePath) //nolint:gosec // reading test-generated template file
if err != nil {
t.Fatalf("failed to read template: %v", err)
}

View File

@@ -32,7 +32,7 @@ type ProxyBridge struct {
// 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).
// On the host, socat forwards from the Unix socket to the actual DNS server (UDP).
type DnsBridge struct {
SocketPath string // Unix socket path
DnsAddr string // Host-side DNS address (host:port)
@@ -61,10 +61,10 @@ func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
debug: debug,
}
// Start bridge: Unix socket -> DNS server TCP
// Start bridge: Unix socket -> DNS server UDP
socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("TCP:%s", dnsAddr),
fmt.Sprintf("UDP:%s", dnsAddr),
}
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
@@ -371,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string {
paths = append(paths, p)
}
// Sensitive project files (e.g. .env) in cwd
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
paths = append(paths, p)
}
// Git hooks in cwd
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
@@ -389,6 +395,203 @@ func getMandatoryDenyPaths(cwd string) []string {
return paths
}
// buildDenyByDefaultMounts builds bwrap arguments for deny-by-default filesystem isolation.
// Starts with --tmpfs / (empty root), then selectively mounts system paths read-only,
// CWD read-write, and user tooling paths read-only. Sensitive files within CWD are masked.
func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []string {
var args []string
home, _ := os.UserHomeDir()
// Start with empty root
args = append(args, "--tmpfs", "/")
// System paths (read-only) - on modern distros (Arch, Fedora, etc.),
// /bin, /sbin, /lib, /lib64 are often symlinks to /usr/*. We must
// recreate these as symlinks via --symlink so the dynamic linker
// and shell can be found. Real directories get bind-mounted.
systemPaths := []string{"/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run"}
for _, p := range systemPaths {
if !fileExists(p) {
continue
}
if isSymlink(p) {
// Recreate the symlink inside the sandbox (e.g., /bin -> usr/bin)
target, err := os.Readlink(p)
if err == nil {
args = append(args, "--symlink", target, p)
}
} else {
args = append(args, "--ro-bind", p, p)
}
}
// /sys needs to be accessible for system info
if fileExists("/sys") && canMountOver("/sys") {
args = append(args, "--ro-bind", "/sys", "/sys")
}
// CWD: create intermediary dirs and bind read-write
if cwd != "" && fileExists(cwd) {
for _, dir := range intermediaryDirs("/", cwd) {
// Skip dirs that are already mounted as system paths
if isSystemMountPoint(dir) {
continue
}
args = append(args, "--dir", dir)
}
args = append(args, "--bind", cwd, cwd)
}
// User tooling paths from GetDefaultReadablePaths() (read-only)
// Filter out paths already mounted (system dirs, /dev, /proc, /tmp, macOS-specific)
if home != "" {
boundDirs := make(map[string]bool)
for _, p := range GetDefaultReadablePaths() {
// Skip system paths (already bound above), special mounts, and macOS paths
if isSystemMountPoint(p) || p == "/dev" || p == "/proc" || p == "/sys" ||
p == "/tmp" || p == "/private/tmp" ||
strings.HasPrefix(p, "/System") || strings.HasPrefix(p, "/Library") ||
strings.HasPrefix(p, "/Applications") || strings.HasPrefix(p, "/private/") ||
strings.HasPrefix(p, "/nix") || strings.HasPrefix(p, "/snap") ||
p == "/usr/local" || p == "/opt/homebrew" {
continue
}
if !strings.HasPrefix(p, home) {
continue // Only user tooling paths need intermediary dirs
}
if !fileExists(p) || !canMountOver(p) {
continue
}
// Create intermediary dirs between root and this path
for _, dir := range intermediaryDirs("/", p) {
if !boundDirs[dir] && !isSystemMountPoint(dir) && dir != cwd {
boundDirs[dir] = true
args = append(args, "--dir", dir)
}
}
args = append(args, "--ro-bind", p, p)
}
// Shell config files in home (read-only, literal files)
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
homeIntermedaryAdded := boundDirs[home]
for _, f := range shellConfigs {
p := filepath.Join(home, f)
if fileExists(p) && canMountOver(p) {
if !homeIntermedaryAdded {
for _, dir := range intermediaryDirs("/", home) {
if !boundDirs[dir] && !isSystemMountPoint(dir) {
boundDirs[dir] = true
args = append(args, "--dir", dir)
}
}
homeIntermedaryAdded = true
}
args = append(args, "--ro-bind", p, p)
}
}
// Home tool caches (read-only, for package managers/configs)
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"}
for _, d := range homeCaches {
p := filepath.Join(home, d)
if fileExists(p) && canMountOver(p) {
if !homeIntermedaryAdded {
for _, dir := range intermediaryDirs("/", home) {
if !boundDirs[dir] && !isSystemMountPoint(dir) {
boundDirs[dir] = true
args = append(args, "--dir", dir)
}
}
homeIntermedaryAdded = true
}
args = append(args, "--ro-bind", p, p)
}
}
}
// User-specified allowRead paths (read-only)
if cfg != nil && cfg.Filesystem.AllowRead != nil {
boundPaths := make(map[string]bool)
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
for _, p := range expandedPaths {
if fileExists(p) && canMountOver(p) &&
!strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
boundPaths[p] = true
// Create intermediary dirs if needed.
// For files, only create dirs up to the parent to avoid
// creating a directory at the file's path.
dirTarget := p
if !isDirectory(p) {
dirTarget = filepath.Dir(p)
}
for _, dir := range intermediaryDirs("/", dirTarget) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}
}
args = append(args, "--ro-bind", p, p)
}
}
for _, p := range cfg.Filesystem.AllowRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) && canMountOver(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
dirTarget := normalized
if !isDirectory(normalized) {
dirTarget = filepath.Dir(normalized)
}
for _, dir := range intermediaryDirs("/", dirTarget) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}
}
args = append(args, "--ro-bind", normalized, normalized)
}
}
}
// Mask sensitive project files within CWD by overlaying an empty regular file.
// We use an empty file instead of /dev/null because Landlock's READ_FILE right
// doesn't cover character devices, causing "Permission denied" on /dev/null mounts.
if cwd != "" {
var emptyFile string
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
if fileExists(p) {
if emptyFile == "" {
emptyFile = filepath.Join(os.TempDir(), "greywall", "empty")
_ = os.MkdirAll(filepath.Dir(emptyFile), 0o750)
_ = os.WriteFile(emptyFile, nil, 0o444) //nolint:gosec // intentionally world-readable empty file for bind-mount masking
}
args = append(args, "--ro-bind", emptyFile, p)
if debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] Masking sensitive file: %s\n", p)
}
}
}
}
return args
}
// isSystemMountPoint returns true if the path is a top-level system directory
// that gets mounted directly under --tmpfs / (bwrap auto-creates these).
func isSystemMountPoint(path string) bool {
switch path {
case "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/run", "/sys",
"/dev", "/proc", "/tmp",
// macOS
"/System", "/Library", "/Applications", "/private",
// Package managers
"/nix", "/snap", "/usr/local", "/opt/homebrew":
return true
}
return false
}
// 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, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
@@ -422,9 +625,15 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// Build bwrap args with filesystem restrictions
bwrapArgs := []string{
"bwrap",
"--new-session",
"--die-with-parent",
}
// --new-session calls setsid() which detaches from the controlling terminal.
// Skip it in learning mode so interactive programs (TUIs, prompts) can
// read from /dev/tty. Learning mode already relaxes security constraints
// (no seccomp, no landlock), so skipping new-session is acceptable.
if !opts.Learning {
bwrapArgs = append(bwrapArgs, "--new-session")
}
bwrapArgs = append(bwrapArgs, "--die-with-parent")
// Always use --unshare-net when available (network namespace isolation)
// Inside the namespace, tun2socks will provide transparent proxy access
@@ -472,54 +681,29 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd)
}
// Make XDG_RUNTIME_DIR writable so dconf and other runtime services
// (Wayland, PulseAudio, D-Bus) work inside the sandbox.
// Writes to /run/ are already filtered out by the learning parser.
xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
if xdgRuntime != "" && fileExists(xdgRuntime) {
bwrapArgs = append(bwrapArgs, "--bind", xdgRuntime, xdgRuntime)
}
}
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
if opts.Learning {
switch {
case opts.Learning:
// Skip defaultDenyRead logic in learning mode (already set up above)
} else if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only
// and user-specified allowRead paths. Everything else is inaccessible.
case defaultDenyRead:
// Deny-by-default mode: start with empty root, then whitelist system paths + CWD
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n")
}
// Bind essential system paths read-only
// Skip /dev, /proc, /tmp as they're mounted with special options below
for _, systemPath := range GetDefaultReadablePaths() {
if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" ||
systemPath == "/private/tmp" {
continue
}
if fileExists(systemPath) {
bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath)
}
}
// Bind user-specified allowRead paths
if cfg != nil && cfg.Filesystem.AllowRead != nil {
boundPaths := make(map[string]bool)
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
for _, p := range expandedPaths {
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
boundPaths[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
}
} else {
// Default mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
default:
// Legacy mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
}
@@ -673,10 +857,20 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd)
// In deny-by-default mode, sensitive project files are already masked
// with --ro-bind /dev/null by buildDenyByDefaultMounts(). Skip them here
// to avoid overriding the /dev/null mask with a real ro-bind.
maskedPaths := make(map[string]bool)
if defaultDenyRead {
for _, f := range SensitiveProjectFiles {
maskedPaths[filepath.Join(cwd, f)] = true
}
}
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) {
if !seen[p] && fileExists(p) && !maskedPaths[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
@@ -744,7 +938,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// Supported by glibc, Go 1.21+, c-ares, and most DNS resolver libraries.
_, _ = tmpResolv.WriteString("nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions use-vc\n")
}
tmpResolv.Close()
_ = tmpResolv.Close()
dnsRelayResolvConf = tmpResolv.Name()
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
if opts.Debug {
@@ -782,6 +976,14 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping Landlock wrapper (running as library, not greywall CLI)\n")
}
// Bind-mount the greywall binary into the sandbox so the Landlock wrapper
// can re-execute it. Without this, running greywall from a directory that
// isn't the CWD (e.g., ~/bin/greywall from /home/user/project) would fail
// because the binary path doesn't exist inside the sandbox.
if useLandlockWrapper && greywallExePath != "" {
bwrapArgs = append(bwrapArgs, "--ro-bind", greywallExePath, greywallExePath)
}
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up tun2socks and runs the user command
@@ -886,47 +1088,26 @@ sleep 0.3
`)
// In learning mode, wrap the command with strace to trace syscalls.
// strace -f follows forked children, which means it hangs if the app spawns
// long-lived child processes (LSP servers, file watchers, etc.).
// To handle this, we run strace in the background and spawn a monitor that
// detects when the main command (strace's direct child) exits by polling
// /proc/STRACE_PID/task/STRACE_PID/children, then kills strace.
if opts.Learning && opts.StraceLogPath != "" {
innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access
strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s &
GREYWALL_STRACE_PID=$!
# Monitor: detect when the main command exits, then kill strace.
# strace's direct child is the command. When it exits, the children file
# becomes empty (grandchildren are reparented to init in the PID namespace).
(
sleep 1
while kill -0 $GREYWALL_STRACE_PID 2>/dev/null; do
CHILDREN=$(cat /proc/$GREYWALL_STRACE_PID/task/$GREYWALL_STRACE_PID/children 2>/dev/null)
if [ -z "$CHILDREN" ]; then
sleep 0.5
kill $GREYWALL_STRACE_PID 2>/dev/null
break
fi
sleep 1
done
) &
GREYWALL_MONITOR_PID=$!
trap 'kill -INT $GREYWALL_STRACE_PID 2>/dev/null' INT
trap 'kill -TERM $GREYWALL_STRACE_PID 2>/dev/null' TERM
wait $GREYWALL_STRACE_PID 2>/dev/null
kill $GREYWALL_MONITOR_PID 2>/dev/null
wait $GREYWALL_MONITOR_PID 2>/dev/null
// Run strace in the foreground so the traced command retains terminal
// access (stdin, /dev/tty) for interactive programs like TUIs.
// If the app spawns long-lived child processes, strace -f may hang
// after the main command exits; the user can Ctrl+C to stop it.
// A SIGCHLD trap kills strace once its direct child exits, handling
// the common case of background daemons (LSP servers, watchers).
switch {
case opts.Learning && opts.StraceLogPath != "":
innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access (foreground for terminal access)
strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s
GREYWALL_STRACE_EXIT=$?
# Kill any orphaned child processes (LSP servers, file watchers, etc.)
# that were spawned by the traced command and reparented to PID 1.
# Without this, greywall hangs until they exit (they hold pipe FDs open).
kill -TERM -1 2>/dev/null
sleep 0.1
exit $GREYWALL_STRACE_EXIT
`,
ShellQuoteSingle(opts.StraceLogPath), command,
))
} else if useLandlockWrapper {
case useLandlockWrapper:
// Use Landlock wrapper if available
// Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied
@@ -947,7 +1128,7 @@ sleep 0.1
// Use exec to replace bash with the wrapper (which will exec the command)
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
} else {
default:
innerScript.WriteString(command)
innerScript.WriteString("\n")
}

View File

@@ -276,6 +276,107 @@ func (f *LinuxFeatures) MinimumViable() bool {
return f.HasBwrap && f.HasSocat
}
// PrintDependencyStatus prints dependency status with install suggestions for Linux.
func PrintDependencyStatus() {
features := DetectLinuxFeatures()
fmt.Printf("\n Platform: linux (kernel %d.%d)\n", features.KernelMajor, features.KernelMinor)
fmt.Printf("\n Dependencies (required):\n")
allGood := true
if features.HasBwrap {
fmt.Printf(" ✓ bubblewrap (bwrap)\n")
} else {
fmt.Printf(" ✗ bubblewrap (bwrap) — REQUIRED\n")
allGood = false
}
if features.HasSocat {
fmt.Printf(" ✓ socat\n")
} else {
fmt.Printf(" ✗ socat — REQUIRED\n")
allGood = false
}
if !allGood {
fmt.Printf("\n Install missing dependencies:\n")
fmt.Printf(" %s\n", suggestInstallCmd(features))
}
fmt.Printf("\n Security features: %s\n", features.Summary())
if features.CanUseTransparentProxy() {
fmt.Printf(" Transparent proxy: available\n")
} else {
parts := []string{}
if !features.HasIpCommand {
parts = append(parts, "iproute2")
}
if !features.HasDevNetTun {
parts = append(parts, "/dev/net/tun")
}
if !features.CanUnshareNet {
parts = append(parts, "network namespace")
}
if len(parts) > 0 {
fmt.Printf(" Transparent proxy: unavailable (missing: %s)\n", strings.Join(parts, ", "))
} else {
fmt.Printf(" Transparent proxy: unavailable\n")
}
if !features.CanUnshareNet && features.HasBwrap {
if val := readSysctl("kernel/apparmor_restrict_unprivileged_userns"); val == "1" {
fmt.Printf("\n Note: AppArmor is restricting unprivileged user namespaces.\n")
fmt.Printf(" This prevents bwrap --unshare-net (needed for transparent proxy).\n")
fmt.Printf(" To fix: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n")
fmt.Printf(" Persist: echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/99-greywall-userns.conf\n")
}
}
}
if allGood {
fmt.Printf("\n Status: ready\n")
} else {
fmt.Printf("\n Status: missing required dependencies\n")
}
}
func suggestInstallCmd(features *LinuxFeatures) string {
var missing []string
if !features.HasBwrap {
missing = append(missing, "bubblewrap")
}
if !features.HasSocat {
missing = append(missing, "socat")
}
pkgs := strings.Join(missing, " ")
switch {
case commandExists("apt-get"):
return fmt.Sprintf("sudo apt install %s", pkgs)
case commandExists("dnf"):
return fmt.Sprintf("sudo dnf install %s", pkgs)
case commandExists("yum"):
return fmt.Sprintf("sudo yum install %s", pkgs)
case commandExists("pacman"):
return fmt.Sprintf("sudo pacman -S %s", pkgs)
case commandExists("apk"):
return fmt.Sprintf("sudo apk add %s", pkgs)
case commandExists("zypper"):
return fmt.Sprintf("sudo zypper install %s", pkgs)
default:
return fmt.Sprintf("install %s using your package manager", pkgs)
}
}
func readSysctl(name string) string {
data, err := os.ReadFile("/proc/sys/" + name) //nolint:gosec // reading sysctl values - trusted kernel path
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil

View File

@@ -2,6 +2,12 @@
package sandbox
import (
"fmt"
"os/exec"
"runtime"
)
// LinuxFeatures describes available Linux sandboxing features.
// This is a stub for non-Linux platforms.
type LinuxFeatures struct {
@@ -51,3 +57,21 @@ func (f *LinuxFeatures) CanUseTransparentProxy() bool {
func (f *LinuxFeatures) MinimumViable() bool {
return false
}
// PrintDependencyStatus prints dependency status for non-Linux platforms.
func PrintDependencyStatus() {
if runtime.GOOS == "darwin" {
fmt.Printf("\n Platform: macOS\n")
fmt.Printf("\n Dependencies (required):\n")
if _, err := exec.LookPath("sandbox-exec"); err == nil {
fmt.Printf(" ✓ sandbox-exec (Seatbelt)\n")
fmt.Printf("\n Status: ready\n")
} else {
fmt.Printf(" ✗ sandbox-exec — REQUIRED (should be built-in on macOS)\n")
fmt.Printf("\n Status: missing required dependencies\n")
}
} else {
fmt.Printf("\n Platform: %s (unsupported)\n", runtime.GOOS)
fmt.Printf("\n Status: this platform is not supported\n")
}
}

View File

@@ -81,17 +81,55 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
}
}
// Current working directory - read access (may be upgraded to write below)
// Current working directory - read+write access (project directory)
if cwd != "" {
if err := ruleset.AllowRead(cwd); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err)
if err := ruleset.AllowReadWrite(cwd); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write 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, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
// Home directory - read access only when not in deny-by-default mode.
// In deny-by-default mode, only specific user tooling paths are allowed,
// not the entire home directory. Landlock can't selectively deny files
// within an allowed directory, so we rely on bwrap mount overlays for
// .env file masking.
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
if !defaultDenyRead {
if home, err := os.UserHomeDir(); err == nil {
if err := ruleset.AllowRead(home); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
}
}
} else {
// In deny-by-default mode, allow specific user tooling paths
if home, err := os.UserHomeDir(); err == nil {
for _, p := range GetDefaultReadablePaths() {
if strings.HasPrefix(p, home) {
if err := ruleset.AllowRead(p); err != nil && debug {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add user tooling path %s: %v\n", p, err)
}
}
}
// Shell configs
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
for _, f := range shellConfigs {
p := filepath.Join(home, f)
if err := ruleset.AllowRead(p); err != nil && debug {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add shell config %s: %v\n", p, err)
}
}
}
// Home caches
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config"}
for _, d := range homeCaches {
p := filepath.Join(home, d)
if err := ruleset.AllowRead(p); err != nil && debug {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home cache %s: %v\n", p, err)
}
}
}
}
}
@@ -163,7 +201,7 @@ type LandlockRuleset struct {
func NewLandlockRuleset(debug bool) (*LandlockRuleset, error) {
features := DetectLinuxFeatures()
if !features.CanUseLandlock() {
return nil, fmt.Errorf("Landlock not available (kernel %d.%d, need 5.13+)",
return nil, fmt.Errorf("landlock not available (kernel %d.%d, need 5.13+)",
features.KernelMajor, features.KernelMinor)
}
@@ -400,7 +438,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
// Apply applies the Landlock ruleset to the current process.
func (l *LandlockRuleset) Apply() error {
if !l.initialized {
return fmt.Errorf("Landlock ruleset not initialized")
return fmt.Errorf("landlock ruleset not initialized")
}
// Set NO_NEW_PRIVS first (required for Landlock)

View File

@@ -37,6 +37,7 @@ type MacOSSandboxParams struct {
AllowLocalBinding bool
AllowLocalOutbound bool
DefaultDenyRead bool
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
ReadAllowPaths []string
ReadDenyPaths []string
WriteAllowPaths []string
@@ -146,13 +147,13 @@ func getTmpdirParent() []string {
}
// generateReadRules generates filesystem read rules for the sandbox profile.
func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, logTag string) []string {
func generateReadRules(defaultDenyRead bool, cwd string, allowPaths, denyPaths []string, logTag string) []string {
var rules []string
if defaultDenyRead {
// When defaultDenyRead is enabled:
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.)
// 2. Allow file-read-data only for system paths + user-specified allowRead paths
// 2. Allow file-read-data only for system paths + CWD + user-specified allowRead paths
// This lets programs see what files exist but not read their contents.
// Allow metadata operations globally (stat, readdir, etc.) and root dir (for path resolution)
@@ -167,6 +168,44 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
)
}
// Allow reading CWD (full recursive read access)
if cwd != "" {
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(cwd)),
)
// Allow ancestor directory traversal (literal only, so programs can resolve CWD path)
for _, ancestor := range getAncestorDirectories(cwd) {
rules = append(rules,
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(ancestor)),
)
}
}
// Allow home shell configs and tool caches (read-only)
home, _ := os.UserHomeDir()
if home != "" {
// Shell config files (literal access)
shellConfigs := []string{".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile", ".zshenv", ".inputrc"}
for _, f := range shellConfigs {
p := filepath.Join(home, f)
rules = append(rules,
fmt.Sprintf("(allow file-read-data (literal %s))", escapePath(p)),
)
}
// Home tool caches (subpath access for package managers/configs)
homeCaches := []string{".cache", ".npm", ".cargo", ".rustup", ".local", ".config", ".nvm", ".pyenv", ".rbenv", ".asdf"}
for _, d := range homeCaches {
p := filepath.Join(home, d)
rules = append(rules,
"(allow file-read-data",
fmt.Sprintf(" (subpath %s))", escapePath(p)),
)
}
}
// Allow reading data from user-specified paths
for _, pathPattern := range allowPaths {
normalized := NormalizePath(pathPattern)
@@ -184,6 +223,24 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
)
}
}
// Deny sensitive files within CWD (Seatbelt evaluates deny before allow)
if cwd != "" {
for _, f := range SensitiveProjectFiles {
p := filepath.Join(cwd, f)
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (literal %s)", escapePath(p)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Also deny .env.* pattern via regex
rules = append(rules,
"(deny file-read*",
fmt.Sprintf(" (regex %s)", escapePath("^"+regexp.QuoteMeta(cwd)+"/\\.env\\..*$")),
fmt.Sprintf(" (with message %q))", logTag),
)
}
} else {
// Allow all reads by default
rules = append(rules, "(allow file-read*)")
@@ -220,9 +277,19 @@ func generateReadRules(defaultDenyRead bool, allowPaths, denyPaths []string, log
}
// generateWriteRules generates filesystem write rules for the sandbox profile.
func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
// When cwd is non-empty, it is automatically included in the write allow paths.
func generateWriteRules(cwd string, allowPaths, denyPaths []string, allowGitConfig bool, logTag string) []string {
var rules []string
// Auto-allow CWD for writes (project directory should be writable)
if cwd != "" {
rules = append(rules,
"(allow file-write*",
fmt.Sprintf(" (subpath %s)", escapePath(cwd)),
fmt.Sprintf(" (with message %q))", logTag),
)
}
// Allow TMPDIR parent on macOS
for _, tmpdirParent := range getTmpdirParent() {
normalized := NormalizePath(tmpdirParent)
@@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log
}
// Combine user-specified and mandatory deny patterns
cwd, _ := os.Getwd()
mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig)
mandatoryCwd := cwd
if mandatoryCwd == "" {
mandatoryCwd, _ = os.Getwd()
}
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
allDenyPaths = append(allDenyPaths, denyPaths...)
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
@@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// Read rules
profile.WriteString("; File read\n")
for _, rule := range generateReadRules(params.DefaultDenyRead, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
for _, rule := range generateReadRules(params.DefaultDenyRead, params.Cwd, params.ReadAllowPaths, params.ReadDenyPaths, logTag) {
profile.WriteString(rule + "\n")
}
profile.WriteString("\n")
// Write rules
profile.WriteString("; File write\n")
for _, rule := range generateWriteRules(params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
for _, rule := range generateWriteRules(params.Cwd, params.WriteAllowPaths, params.WriteDenyPaths, params.AllowGitConfig, logTag) {
profile.WriteString(rule + "\n")
}
@@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
cwd, _ := os.Getwd()
// Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
@@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: cwd,
ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,

View File

@@ -1,6 +1,7 @@
package sandbox
import (
"fmt"
"strings"
"testing"
@@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
AllowLocalOutbound: allowLocalOutbound,
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
Cwd: "/tmp/test-project",
ReadAllowPaths: cfg.Filesystem.AllowRead,
ReadDenyPaths: cfg.Filesystem.DenyRead,
WriteAllowPaths: allowPaths,
@@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
tests := []struct {
name string
defaultDenyRead bool
cwd string
allowRead []string
wantContainsBlanketAllow bool
wantContainsMetadataAllow bool
wantContainsSystemAllows bool
wantContainsUserAllowRead bool
wantContainsCwdAllow bool
}{
{
name: "default mode - blanket allow read",
name: "legacy mode - blanket allow read",
defaultDenyRead: false,
cwd: "/home/user/project",
allowRead: nil,
wantContainsBlanketAllow: true,
wantContainsMetadataAllow: false,
wantContainsSystemAllows: false,
wantContainsUserAllowRead: false,
wantContainsCwdAllow: false,
},
{
name: "defaultDenyRead enabled - metadata allow, system data allows",
name: "defaultDenyRead enabled - metadata allow, system data allows, CWD allow",
defaultDenyRead: true,
cwd: "/home/user/project",
allowRead: nil,
wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true,
wantContainsSystemAllows: true,
wantContainsUserAllowRead: false,
wantContainsCwdAllow: true,
},
{
name: "defaultDenyRead with allowRead paths",
defaultDenyRead: true,
allowRead: []string{"/home/user/project"},
cwd: "/home/user/project",
allowRead: []string{"/home/user/other"},
wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true,
wantContainsSystemAllows: true,
wantContainsUserAllowRead: true,
wantContainsCwdAllow: true,
},
}
@@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
DefaultDenyRead: tt.defaultDenyRead,
Cwd: tt.cwd,
ReadAllowPaths: tt.allowRead,
}
@@ -236,6 +247,13 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
}
if tt.wantContainsCwdAllow && tt.cwd != "" {
hasCwdAllow := strings.Contains(profile, fmt.Sprintf(`(subpath %q)`, tt.cwd))
if !hasCwdAllow {
t.Errorf("CWD path %q not found in profile", tt.cwd)
}
}
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
if !hasUserAllow {

View File

@@ -78,7 +78,7 @@ func (m *Manager) Initialize() error {
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
if err != nil {
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
_ = os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
}
@@ -90,7 +90,7 @@ func (m *Manager) Initialize() error {
if err != nil {
m.proxyBridge.Cleanup()
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
_ = os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize DNS bridge: %w", err)
}
@@ -108,7 +108,7 @@ func (m *Manager) Initialize() error {
m.proxyBridge.Cleanup()
}
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
_ = os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
}
@@ -120,7 +120,11 @@ func (m *Manager) Initialize() error {
m.initialized = true
if m.config.Network.ProxyURL != "" {
m.logDebug("Sandbox manager initialized (proxy: %s)", m.config.Network.ProxyURL)
dnsInfo := "none"
if m.config.Network.DnsAddr != "" {
dnsInfo = m.config.Network.DnsAddr
}
m.logDebug("Sandbox manager initialized (proxy: %s, dns: %s)", m.config.Network.ProxyURL, dnsInfo)
} else {
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
}
@@ -162,7 +166,7 @@ func (m *Manager) wrapCommandLearning(command string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create strace log file: %w", err)
}
tmpFile.Close()
_ = tmpFile.Close()
m.straceLogPath = tmpFile.Name()
m.logDebug("Strace log file: %s", m.straceLogPath)
@@ -189,7 +193,7 @@ func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
}
// Clean up strace log since we've processed it
os.Remove(m.straceLogPath)
_ = os.Remove(m.straceLogPath)
m.straceLogPath = ""
return templatePath, nil
@@ -207,10 +211,10 @@ func (m *Manager) Cleanup() {
m.proxyBridge.Cleanup()
}
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
_ = os.Remove(m.tun2socksPath)
}
if m.straceLogPath != "" {
os.Remove(m.straceLogPath)
_ = os.Remove(m.straceLogPath)
m.straceLogPath = ""
}
m.logDebug("Sandbox manager cleaned up")

View File

@@ -38,14 +38,14 @@ func extractTun2Socks() (string, error) {
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
return "", fmt.Errorf("tun2socks: failed to write binary: %w", err)
}
tmpFile.Close()
_ = tmpFile.Close()
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil {
os.Remove(tmpFile.Name())
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil { //nolint:gosec // executable binary needs execute permission
_ = os.Remove(tmpFile.Name())
return "", fmt.Errorf("tun2socks: failed to make executable: %w", err)
}

View File

@@ -149,5 +149,5 @@ git push origin "$NEW_VERSION"
echo ""
info "✓ Released $NEW_VERSION"
info "GitHub Actions will now build and publish the release."
info "Gitea Actions will now build and publish the release."
info "Watch progress at: https://gitea.app.monadical.io/monadical/greywall/actions"