17 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
3dd772d35a feat: add --learning mode, --template flag, and fix DNS relay
Some checks failed
Build and test / Lint (push) Failing after 1m29s
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 58s
Build and test / Test (macOS) (push) Has been cancelled
Learning mode (--learning) traces filesystem access with strace and
generates minimal sandbox config templates. A background monitor kills
strace when the main command exits so long-lived child processes (LSP
servers, file watchers) don't cause hangs.

Other changes:
- Add 'greywall templates list/show' subcommand
- Add --template flag to load specific learned templates
- Fix DNS relay: use TCP DNS (options use-vc) instead of broken UDP
  relay through tun2socks
- Filter O_DIRECTORY opens from learned read paths
- Add docs/experience.md with development notes
2026-02-11 08:22:53 -06:00
631db40665 remove banner image and assets directory
Some checks failed
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 42s
Build and test / Lint (push) Failing after 1m23s
Build and test / Test (macOS) (push) Has been cancelled
2026-02-10 16:23:19 -06:00
34 changed files with 3553 additions and 296 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

126
README.md
View File

@@ -1,23 +1,21 @@
![Greywall Banner](assets/greywall-banner.png)
# Greywall
**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
@@ -41,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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

View File

@@ -4,14 +4,18 @@ package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"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"
)
@@ -34,6 +38,8 @@ var (
exitCode int
showVersion bool
linuxFeatures bool
learning bool
templateName string
)
func main() {
@@ -50,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
@@ -68,6 +74,7 @@ Examples:
greywall -c "echo hello && ls" # Run with shell expansion
greywall --settings config.json npm install
greywall -p 3000 -c "npm run dev" # Expose port 3000
greywall --learning -- opencode # Learn filesystem needs
Configuration file format:
{
@@ -92,16 +99,21 @@ 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")
rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit")
rootCmd.Flags().BoolVar(&learning, "learning", false, "Run in learning mode: trace filesystem access and generate a config template")
rootCmd.Flags().StringVar(&templateName, "template", "", "Load a specific learned template by name (see: greywall templates list)")
rootCmd.Flags().SetInterspersed(true)
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)
@@ -112,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
}
@@ -175,6 +184,44 @@ func runCommand(cmd *cobra.Command, args []string) error {
}
}
// Extract command name for learned template lookup
cmdName := extractCommandName(args, cmdString)
// Load learned template (when NOT in learning mode)
if !learning {
// Determine which template to load: --template flag takes priority
var templatePath string
var templateLabel string
if templateName != "" {
templatePath = sandbox.LearnedTemplatePath(templateName)
templateLabel = templateName
} else if cmdName != "" {
templatePath = sandbox.LearnedTemplatePath(cmdName)
templateLabel = cmdName
}
if templatePath != "" {
learnedCfg, loadErr := config.Load(templatePath)
switch {
case loadErr != nil:
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template: %v\n", loadErr)
}
case learnedCfg != nil:
cfg = config.Merge(cfg, learnedCfg)
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel)
}
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)
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)
}
}
}
// CLI flags override config
if proxyURL != "" {
cfg.Network.ProxyURL = proxyURL
@@ -183,8 +230,54 @@ func runCommand(cmd *cobra.Command, args []string) error {
cfg.Network.DnsAddr = dnsAddr
}
// 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 credentials to %q:proxy\n", proxyUser)
}
}
}
// Learning mode setup
if learning {
if err := sandbox.CheckStraceAvailable(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[greywall] Learning mode: tracing filesystem access for %q\n", cmdName)
fmt.Fprintf(os.Stderr, "[greywall] WARNING: The sandbox filesystem is relaxed during learning. Do not use for untrusted code.\n")
}
manager := sandbox.NewManager(cfg, debug, monitor)
manager.SetExposedPorts(ports)
if learning {
manager.SetLearning(true)
manager.SetCommandName(cmdName)
}
defer manager.Cleanup()
if err := manager.Initialize(); err != nil {
@@ -263,18 +356,144 @@ 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()
return nil
commandFailed = true
} else {
return fmt.Errorf("command failed: %w", err)
}
}
// 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() {
if commandFailed {
fmt.Fprintf(os.Stderr, "[greywall] Skipping template generation: command exited with code %d\n", exitCode)
} else {
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")
}
}
return fmt.Errorf("command failed: %w", err)
}
return nil
}
// extractCommandName extracts a human-readable command name from the arguments.
// For args like ["opencode"], returns "opencode".
// For -c "opencode --foo", returns "opencode".
// Strips path prefixes (e.g., /usr/bin/opencode -> opencode).
func extractCommandName(args []string, cmdStr string) string {
var name string
switch {
case len(args) > 0:
name = args[0]
case cmdStr != "":
// Take first token from the command string
parts := strings.Fields(cmdStr)
if len(parts) > 0 {
name = parts[0]
}
}
if name == "" {
return ""
}
// Strip path prefix
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{
@@ -320,6 +539,71 @@ ${fpath[1]}/_greywall for zsh, ~/.config/fish/completions/greywall.fish for fish
return cmd
}
// newTemplatesCmd creates the templates subcommand for managing learned templates.
func newTemplatesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "templates",
Short: "Manage learned sandbox templates",
Long: `List and inspect learned sandbox templates.
Templates are created by running greywall with --learning and are stored in:
` + sandbox.LearnedTemplateDir() + `
Examples:
greywall templates list # List all learned templates
greywall templates show opencode # Show the content of a template`,
}
listCmd := &cobra.Command{
Use: "list",
Short: "List all learned templates",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
templates, err := sandbox.ListLearnedTemplates()
if err != nil {
return fmt.Errorf("failed to list templates: %w", err)
}
if len(templates) == 0 {
fmt.Println("No learned templates found.")
fmt.Printf("Create one with: greywall --learning -- <command>\n")
return nil
}
fmt.Printf("Learned templates (%s):\n\n", sandbox.LearnedTemplateDir())
for _, t := range templates {
fmt.Printf(" %s\n", t.Name)
}
fmt.Println()
fmt.Println("Show a template: greywall templates show <name>")
fmt.Println("Use a template: greywall --template <name> -- <command>")
return nil
},
}
showCmd := &cobra.Command{
Use: "show <name>",
Short: "Show the content of a learned template",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
templatePath := sandbox.LearnedTemplatePath(name)
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)
}
return fmt.Errorf("failed to read template: %w", err)
}
fmt.Printf("Template: %s\n", name)
fmt.Printf("Path: %s\n\n", templatePath)
fmt.Print(string(data))
return nil
},
}
cmd.AddCommand(listCmd, showCmd)
return cmd
}
// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
// It applies Landlock restrictions and then execs the user command.
// Usage: greywall --landlock-apply [--debug] -- <command...>

121
docs/experience.md Normal file
View File

@@ -0,0 +1,121 @@
# Greywall Development Notes
Lessons learned and issues encountered during development.
---
## strace log hidden by tmpfs mount ordering
**Problem:** Learning mode strace log was always empty ("No additional write paths discovered"). The log file was bind-mounted into `/tmp/greywall-strace-*.log` inside the sandbox, but `--tmpfs /tmp` was declared later in the bwrap args, creating a fresh tmpfs that hid the bind-mount.
**Fix:** Move the strace log bind-mount to AFTER `--tmpfs /tmp` in the bwrap argument list. Later mounts override earlier ones for the same path.
---
## strace -f hangs on long-lived child processes
**Problem:** `greywall --learning -- opencode` would hang after exiting opencode. `strace -f` follows forked children and waits for ALL of them to exit. Apps like opencode spawn LSP servers, file watchers, etc. that outlive the main process.
**Approach 1 - Attach via strace -p:** Run the command in the background, attach strace with `-p PID`. Failed because bwrap restricts `ptrace(PTRACE_SEIZE)` — ptrace only works parent-to-child, not for attaching to arbitrary processes.
**Approach 2 - Background monitor:** Run `strace -- command &` and spawn a monitor subshell that polls `/proc/STRACE_PID/task/STRACE_PID/children`. When strace's direct child (the main command) exits, the children file becomes empty — grandchildren are reparented to PID 1, not strace. Monitor then kills strace.
**Fix:** Approach 2 with two additional fixes:
- Added `-I2` flag to strace. Default `-I3` (used when `-o FILE PROG`) blocks all fatal signals, so the monitor's `kill` was silently ignored.
- Added `kill -TERM -1` after strace exits to clean up orphaned processes. Without this, orphans inherit stdout/stderr pipe FDs, and Go's `cmd.Wait()` blocks until they close.
---
## UDP DNS doesn't work through tun2socks
**Problem:** DNS resolution failed inside the sandbox. The socat DNS relay converted UDP DNS queries to UDP and sent them to 1.1.1.1:53 through tun2socks, but tun2socks (v2.5.2) doesn't reliably handle UDP DNS forwarding through SOCKS5.
**Approach 1 - UDP-to-TCP relay with socat:** Can't work because TCP DNS requires a 2-byte length prefix (RFC 1035 section 4.2.2) that socat can't add.
**Approach 2 - Embed a Go DNS relay binary:** Would work but adds build complexity for a simple problem.
**Fix:** Set resolv.conf to `nameserver 1.1.1.1` with `options use-vc` instead of pointing at a local relay. `use-vc` forces the resolver to use TCP, which tun2socks handles natively. Supported by glibc, Go 1.21+, and c-ares. Removed the broken socat UDP relay entirely.
---
## DNS relay protocol mismatch (original bug)
**Problem:** The original DNS relay used `socat UDP4-RECVFROM:53,fork TCP:1.1.1.1:53` — converting UDP DNS to TCP. This silently fails because TCP DNS requires a 2-byte big-endian length prefix per RFC 1035 section 4.2.2 that raw UDP DNS packets don't have. The DNS server receives a malformed TCP stream and drops it.
**Fix:** Superseded by the `options use-vc` approach above.
---
## strace captures directory traversals as file reads
**Problem:** Learning mode listed `/`, `/home`, `/home/user`, `/home/user/.cache` etc. as "read" paths. These are `openat(O_RDONLY|O_DIRECTORY)` calls used for `readdir()` traversal, not meaningful file reads.
**Fix:** Filter out `openat` calls containing `O_DIRECTORY` in `extractReadPath()`.
---
## SOCKS5 proxy credentials and protocol
**Problem:** DNS resolution through the SOCKS5 proxy failed with authentication errors. Two issues: wrong credentials (`x:x` vs `proxy:proxy`) and wrong protocol (`socks5://` vs `socks5h://`).
**Key distinction:** `socks5://` resolves DNS locally then sends the IP to the proxy. `socks5h://` sends the hostname to the proxy for remote DNS resolution. With tun2socks, the distinction matters less (tun2socks intercepts at IP level), but using `socks5h://` is still correct for the proxy bridge configuration.
---
## gost SOCKS5 requires authentication flow
**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

@@ -26,8 +26,8 @@ type Config struct {
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
@@ -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

@@ -0,0 +1,436 @@
package sandbox
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// wellKnownParents are directories under $HOME where applications typically
// create their own subdirectory (e.g., ~/.cache/opencode, ~/.config/opencode).
var wellKnownParents = []string{
".cache",
".config",
".local/share",
".local/state",
".local/lib",
".data",
}
// LearnedTemplateDir returns the directory where learned templates are stored.
func LearnedTemplateDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "greywall", "learned")
}
return filepath.Join(configDir, "greywall", "learned")
}
// LearnedTemplatePath returns the path where a command's learned template is stored.
func LearnedTemplatePath(cmdName string) string {
return filepath.Join(LearnedTemplateDir(), SanitizeTemplateName(cmdName)+".json")
}
// SanitizeTemplateName sanitizes a command name for use as a filename.
// Only allows alphanumeric, dash, underscore, and dot characters.
func SanitizeTemplateName(name string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9._-]`)
sanitized := re.ReplaceAllString(name, "_")
// Collapse multiple underscores
for strings.Contains(sanitized, "__") {
sanitized = strings.ReplaceAll(sanitized, "__", "_")
}
sanitized = strings.Trim(sanitized, "_.")
if sanitized == "" {
return "unknown"
}
return sanitized
}
// GenerateLearnedTemplate parses an strace log, collapses paths, and saves a template.
// Returns the path where the template was saved.
func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string, error) {
result, err := ParseStraceLog(straceLogPath, debug)
if err != nil {
return "", fmt.Errorf("failed to parse strace log: %w", err)
}
home, _ := os.UserHomeDir()
// Filter write paths: remove default writable and sensitive paths
var filteredWrites []string
for _, p := range result.WritePaths {
if isDefaultWritablePath(p) {
continue
}
if isSensitivePath(p, home) {
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Skipping sensitive path: %s\n", p)
}
continue
}
filteredWrites = append(filteredWrites, p)
}
// Collapse write paths into minimal directory set
collapsed := CollapsePaths(filteredWrites)
// Convert write paths to tilde-relative
var allowWrite []string
allowWrite = append(allowWrite, ".") // Always include cwd
for _, p := range collapsed {
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 {
readDisplay = append(readDisplay, toTildePath(p, home))
}
// Print all discovered paths
fmt.Fprintf(os.Stderr, "\n")
if len(readDisplay) > 0 {
fmt.Fprintf(os.Stderr, "[greywall] Discovered read paths:\n")
for _, p := range readDisplay {
fmt.Fprintf(os.Stderr, "[greywall] %s\n", p)
}
}
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 {
if p == "." {
continue
}
fmt.Fprintf(os.Stderr, "[greywall] %s\n", p)
}
} else {
fmt.Fprintf(os.Stderr, "[greywall] No additional write paths discovered.\n")
}
fmt.Fprintf(os.Stderr, "\n")
// Build template
template := buildTemplate(cmdName, allowRead, allowWrite)
// Save template
templatePath := LearnedTemplatePath(cmdName)
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), 0o600); err != nil {
return "", fmt.Errorf("failed to write template: %w", err)
}
// Display the template content
fmt.Fprintf(os.Stderr, "[greywall] Generated template:\n")
for _, line := range strings.Split(template, "\n") {
if line != "" {
fmt.Fprintf(os.Stderr, "[greywall] %s\n", line)
}
}
fmt.Fprintf(os.Stderr, "\n")
return templatePath, nil
}
// CollapsePaths groups write paths into minimal directory set.
// Uses "application directory" detection for well-known parents.
func CollapsePaths(paths []string) []string {
if len(paths) == 0 {
return nil
}
home, _ := os.UserHomeDir()
// Group paths by application directory
appDirPaths := make(map[string][]string) // appDir -> list of paths
var standalone []string // paths that don't fit an app dir
for _, p := range paths {
appDir := findApplicationDirectory(p, home)
if appDir != "" {
appDirPaths[appDir] = append(appDirPaths[appDir], p)
} else {
standalone = append(standalone, p)
}
}
var result []string
// For each app dir group: if 2+ paths share it, use the app dir
// If only 1 path, use its parent directory
for appDir, groupPaths := range appDirPaths {
if len(groupPaths) >= 2 {
result = append(result, appDir)
} else {
result = append(result, filepath.Dir(groupPaths[0]))
}
}
// For standalone paths, use their parent directory — but never collapse to $HOME
for _, p := range standalone {
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)
sort.Strings(result)
result = deduplicateSubPaths(result)
return result
}
// findApplicationDirectory finds the app-level directory for a path.
// For paths under well-known parents (e.g., ~/.cache/opencode/foo),
// returns the first directory below the well-known parent (e.g., ~/.cache/opencode).
func findApplicationDirectory(path, home string) string {
if home == "" {
return ""
}
for _, parent := range wellKnownParents {
prefix := filepath.Join(home, parent) + "/"
if strings.HasPrefix(path, prefix) {
// Get the first directory below the well-known parent
rest := strings.TrimPrefix(path, prefix)
parts := strings.SplitN(rest, "/", 2)
if len(parts) > 0 && parts[0] != "" {
return filepath.Join(home, parent, parts[0])
}
}
}
return ""
}
// isDefaultWritablePath checks if a path is already writable by default in the sandbox.
func isDefaultWritablePath(path string) bool {
// /tmp is always writable (tmpfs in sandbox)
if strings.HasPrefix(path, "/tmp/") || path == "/tmp" {
return false // /tmp inside sandbox is tmpfs, not host /tmp
}
for _, p := range GetDefaultWritePaths() {
if path == p || strings.HasPrefix(path, p+"/") {
return true
}
}
return false
}
// isSensitivePath checks if a path is sensitive and should not be made writable.
func isSensitivePath(path, home string) bool {
if home == "" {
return false
}
// Check against DangerousFiles
for _, f := range DangerousFiles {
dangerous := filepath.Join(home, f)
if path == dangerous {
return true
}
}
// Check for .env files
base := filepath.Base(path)
if base == ".env" || strings.HasPrefix(base, ".env.") {
return true
}
// Check SSH keys
sshDir := filepath.Join(home, ".ssh")
if strings.HasPrefix(path, sshDir+"/") {
return true
}
// Check GPG
gnupgDir := filepath.Join(home, ".gnupg")
return strings.HasPrefix(path, gnupgDir+"/")
}
// getDangerousFilePatterns returns denyWrite entries for DangerousFiles.
func getDangerousFilePatterns() []string {
var patterns []string
for _, f := range DangerousFiles {
patterns = append(patterns, "~/"+f)
}
return patterns
}
// getSensitiveReadPatterns returns denyRead entries for sensitive data.
func getSensitiveReadPatterns() []string {
return []string{
"~/.ssh/id_*",
"~/.gnupg/**",
}
}
// toTildePath converts an absolute path to a tilde-relative path if under home.
func toTildePath(p, home string) string {
if home != "" && strings.HasPrefix(p, home+"/") {
return "~/" + strings.TrimPrefix(p, home+"/")
}
return p
}
// LearnedTemplateInfo holds metadata about a learned template.
type LearnedTemplateInfo struct {
Name string // template name (without .json)
Path string // full path to the template file
}
// ListLearnedTemplates returns all available learned templates.
func ListLearnedTemplates() ([]LearnedTemplateInfo, error) {
dir := LearnedTemplateDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var templates []LearnedTemplateInfo
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
templates = append(templates, LearnedTemplateInfo{
Name: name,
Path: filepath.Join(dir, e.Name()),
})
}
return templates, nil
}
// deduplicateSubPaths removes paths that are sub-paths of other paths in the list.
// Assumes the input is sorted.
func deduplicateSubPaths(paths []string) []string {
if len(paths) == 0 {
return nil
}
var result []string
for i, p := range paths {
isSubPath := false
for j, other := range paths {
if i == j {
continue
}
if strings.HasPrefix(p, other+"/") {
isSubPath = true
break
}
}
if !isSubPath {
result = append(result, p)
}
}
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, allowRead, allowWrite []string) string {
type fsConfig struct {
AllowRead []string `json:"allowRead,omitempty"`
AllowWrite []string `json:"allowWrite"`
DenyWrite []string `json:"denyWrite"`
DenyRead []string `json:"denyRead"`
}
type templateConfig struct {
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: denyRead,
},
}
data, _ := json.MarshalIndent(cfg, "", " ")
var sb strings.Builder
sb.WriteString(fmt.Sprintf("// Learned template for %q\n", cmdName))
sb.WriteString(fmt.Sprintf("// Generated by: greywall --learning -- %s\n", cmdName))
sb.WriteString("// Review and adjust paths as needed\n")
sb.Write(data)
sb.WriteString("\n")
return sb.String()
}

View File

@@ -0,0 +1,298 @@
//go:build linux
package sandbox
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// straceSyscallRegex matches strace output lines for file-access syscalls.
var straceSyscallRegex = regexp.MustCompile(
`(openat|mkdirat|unlinkat|renameat2|creat|symlinkat|linkat)\(`,
)
// openatWriteFlags matches O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND flags in strace output.
var openatWriteFlags = regexp.MustCompile(`O_(?:WRONLY|RDWR|CREAT|TRUNC|APPEND)`)
// StraceResult holds parsed read and write paths from an strace log.
type StraceResult struct {
WritePaths []string
ReadPaths []string
}
// CheckStraceAvailable verifies that strace is installed and accessible.
func CheckStraceAvailable() error {
_, err := exec.LookPath("strace")
if err != nil {
return fmt.Errorf("strace is required for learning mode but not found: %w\n\nInstall it with: sudo apt install strace (Debian/Ubuntu) or sudo pacman -S strace (Arch)", err)
}
return nil
}
// ParseStraceLog reads an strace output file and extracts unique read and write paths.
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
f, err := os.Open(logPath) //nolint:gosec // user-controlled path from temp file - intentional
if err != nil {
return nil, fmt.Errorf("failed to open strace log: %w", err)
}
defer func() { _ = f.Close() }()
home, _ := os.UserHomeDir()
seenWrite := make(map[string]bool)
seenRead := make(map[string]bool)
result := &StraceResult{}
scanner := bufio.NewScanner(f)
// Increase buffer for long strace lines
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineCount := 0
writeCount := 0
readCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
// Try extracting as a write path first
writePath := extractWritePath(line)
if writePath != "" {
writeCount++
if !shouldFilterPath(writePath, home) && !seenWrite[writePath] {
seenWrite[writePath] = true
result.WritePaths = append(result.WritePaths, writePath)
}
continue
}
// Try extracting as a read path
readPath := extractReadPath(line)
if readPath != "" {
readCount++
if !shouldFilterPath(readPath, home) && !seenRead[readPath] {
seenRead[readPath] = true
result.ReadPaths = append(result.ReadPaths, readPath)
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading strace log: %w", err)
}
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Parsed strace log: %d lines, %d write syscalls, %d read syscalls, %d unique write paths, %d unique read paths\n",
lineCount, writeCount, readCount, len(result.WritePaths), len(result.ReadPaths))
}
return result, nil
}
// extractReadPath parses a single strace line and returns the read path, if any.
// Only matches openat() with O_RDONLY (no write flags).
func extractReadPath(line string) string {
if !strings.Contains(line, "openat(") {
return ""
}
// Skip failed syscalls
if strings.Contains(line, "= -1 ") {
return ""
}
// Skip resumed/unfinished lines
if strings.Contains(line, "<unfinished") || strings.Contains(line, "resumed>") {
return ""
}
// Only care about read-only opens (no write flags)
if openatWriteFlags.MatchString(line) {
return ""
}
// Skip directory opens (O_DIRECTORY) — these are just directory traversal
// (readdir/stat), not meaningful file reads
if strings.Contains(line, "O_DIRECTORY") {
return ""
}
return extractATPath(line)
}
// extractWritePath parses a single strace line and returns the write target path, if any.
func extractWritePath(line string) string {
// Skip lines that don't contain write syscalls
if !straceSyscallRegex.MatchString(line) {
return ""
}
// Skip failed syscalls (lines ending with = -1 ENOENT or similar errors)
if strings.Contains(line, "= -1 ") {
return ""
}
// Skip resumed/unfinished lines
if strings.Contains(line, "<unfinished") || strings.Contains(line, "resumed>") {
return ""
}
// Extract path based on syscall type
if strings.Contains(line, "openat(") {
return extractOpenatPath(line)
}
if strings.Contains(line, "mkdirat(") {
return extractATPath(line)
}
if strings.Contains(line, "unlinkat(") {
return extractATPath(line)
}
if strings.Contains(line, "renameat2(") {
return extractRenameatPath(line)
}
if strings.Contains(line, "creat(") {
return extractCreatPath(line)
}
if strings.Contains(line, "symlinkat(") {
return extractSymlinkTarget(line)
}
if strings.Contains(line, "linkat(") {
return extractLinkatTarget(line)
}
return ""
}
// extractOpenatPath extracts the path from an openat() line, only if write flags are present.
func extractOpenatPath(line string) string {
// Only care about writes
if !openatWriteFlags.MatchString(line) {
return ""
}
return extractATPath(line)
}
// extractATPath extracts the second argument (path) from AT_FDCWD-based syscalls.
// Pattern: syscall(AT_FDCWD, "/path/to/file", ...)
func extractATPath(line string) string {
// Find the first quoted string after AT_FDCWD
idx := strings.Index(line, "AT_FDCWD, \"")
if idx < 0 {
return ""
}
start := idx + len("AT_FDCWD, \"")
end := strings.Index(line[start:], "\"")
if end < 0 {
return ""
}
return line[start : start+end]
}
// extractCreatPath extracts the path from a creat() call.
// Pattern: creat("/path/to/file", mode)
func extractCreatPath(line string) string {
idx := strings.Index(line, "creat(\"")
if idx < 0 {
return ""
}
start := idx + len("creat(\"")
end := strings.Index(line[start:], "\"")
if end < 0 {
return ""
}
return line[start : start+end]
}
// extractRenameatPath extracts the destination path from renameat2().
// Pattern: renameat2(AT_FDCWD, "/old", AT_FDCWD, "/new", flags)
// We want both old and new paths, but primarily the new (destination) path.
func extractRenameatPath(line string) string {
// Find the second AT_FDCWD occurrence for the destination
first := strings.Index(line, "AT_FDCWD, \"")
if first < 0 {
return ""
}
rest := line[first+len("AT_FDCWD, \""):]
endFirst := strings.Index(rest, "\"")
if endFirst < 0 {
return ""
}
rest = rest[endFirst+1:]
// Find second AT_FDCWD
second := strings.Index(rest, "AT_FDCWD, \"")
if second < 0 {
// Fall back to first path
return extractATPath(line)
}
start := second + len("AT_FDCWD, \"")
end := strings.Index(rest[start:], "\"")
if end < 0 {
return extractATPath(line)
}
return rest[start : start+end]
}
// extractSymlinkTarget extracts the link path (destination) from symlinkat().
// Pattern: symlinkat("/target", AT_FDCWD, "/link")
func extractSymlinkTarget(line string) string {
// The link path is the third argument (after AT_FDCWD)
return extractATPath(line)
}
// extractLinkatTarget extracts the new link path from linkat().
// Pattern: linkat(AT_FDCWD, "/old", AT_FDCWD, "/new", flags)
func extractLinkatTarget(line string) string {
return extractRenameatPath(line)
}
// shouldFilterPath returns true if a path should be excluded from learning results.
func shouldFilterPath(path, home string) bool {
// Filter empty or relative paths
if path == "" || !strings.HasPrefix(path, "/") {
return true
}
// Filter system paths
systemPrefixes := []string{
"/proc/",
"/sys/",
"/dev/",
"/run/",
"/var/run/",
"/var/lock/",
}
for _, prefix := range systemPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
// Filter /tmp (sandbox has its own tmpfs)
if strings.HasPrefix(path, "/tmp/") || path == "/tmp" {
return true
}
// Filter shared object files (.so, .so.*)
base := filepath.Base(path)
if strings.HasSuffix(base, ".so") || strings.Contains(base, ".so.") {
return true
}
// Filter greywall infrastructure files
if strings.Contains(path, "greywall-") {
return true
}
// Filter paths outside home (they're typically system-level)
if home != "" && !strings.HasPrefix(path, home+"/") {
return true
}
return false
}

View File

@@ -0,0 +1,243 @@
//go:build linux
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtractWritePath(t *testing.T) {
tests := []struct {
name string
line string
expected string
}{
{
name: "openat with O_WRONLY",
line: `12345 openat(AT_FDCWD, "/home/user/.cache/opencode/db", O_WRONLY|O_CREAT, 0644) = 3`,
expected: "/home/user/.cache/opencode/db",
},
{
name: "openat with O_RDWR",
line: `12345 openat(AT_FDCWD, "/home/user/.cache/opencode/data", O_RDWR|O_CREAT, 0644) = 3`,
expected: "/home/user/.cache/opencode/data",
},
{
name: "openat with O_CREAT",
line: `12345 openat(AT_FDCWD, "/home/user/file.txt", O_CREAT|O_WRONLY, 0644) = 3`,
expected: "/home/user/file.txt",
},
{
name: "openat read-only ignored",
line: `12345 openat(AT_FDCWD, "/home/user/readme.txt", O_RDONLY) = 3`,
expected: "",
},
{
name: "mkdirat",
line: `12345 mkdirat(AT_FDCWD, "/home/user/.cache/opencode", 0755) = 0`,
expected: "/home/user/.cache/opencode",
},
{
name: "unlinkat",
line: `12345 unlinkat(AT_FDCWD, "/home/user/temp.txt", 0) = 0`,
expected: "/home/user/temp.txt",
},
{
name: "creat",
line: `12345 creat("/home/user/newfile", 0644) = 3`,
expected: "/home/user/newfile",
},
{
name: "failed syscall ignored",
line: `12345 openat(AT_FDCWD, "/nonexistent", O_WRONLY|O_CREAT, 0644) = -1 ENOENT (No such file or directory)`,
expected: "",
},
{
name: "unfinished syscall ignored",
line: `12345 openat(AT_FDCWD, "/home/user/file", O_WRONLY <unfinished ...>`,
expected: "",
},
{
name: "non-write syscall ignored",
line: `12345 read(3, "data", 1024) = 5`,
expected: "",
},
{
name: "renameat2 returns destination",
line: `12345 renameat2(AT_FDCWD, "/home/user/old.txt", AT_FDCWD, "/home/user/new.txt", 0) = 0`,
expected: "/home/user/new.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractWritePath(tt.line)
if got != tt.expected {
t.Errorf("extractWritePath(%q) = %q, want %q", tt.line, got, tt.expected)
}
})
}
}
func TestShouldFilterPath(t *testing.T) {
home := "/home/testuser"
tests := []struct {
path string
expected bool
}{
{"/proc/self/maps", true},
{"/sys/kernel/mm/transparent_hugepage", true},
{"/dev/null", true},
{"/tmp/somefile", true},
{"/run/user/1000/bus", true},
{"/home/testuser/.cache/opencode/db", false},
{"/usr/lib/libfoo.so", true}, // .so file
{"/usr/lib/libfoo.so.1", true}, // .so.X file
{"/tmp/greywall-strace-abc.log", true}, // greywall infrastructure
{"relative/path", true}, // relative path
{"", true}, // empty path
{"/other/user/file", true}, // outside home
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := shouldFilterPath(tt.path, home)
if got != tt.expected {
t.Errorf("shouldFilterPath(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
}
})
}
}
func TestParseStraceLog(t *testing.T) {
home, _ := os.UserHomeDir()
logContent := strings.Join([]string{
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db") + `", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/ver") + `", O_WRONLY, 0644) = 4`,
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp/conf.json") + `", O_RDONLY) = 5`,
`12345 openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 6`,
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 7`,
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 8`,
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db") + `", O_WRONLY, 0644) = 9`, // duplicate
}, "\n")
logFile := filepath.Join(t.TempDir(), "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
result, err := ParseStraceLog(logFile, false)
if err != nil {
t.Fatalf("ParseStraceLog() error: %v", err)
}
// Write paths: should have unique home paths only (no /tmp, /proc)
for _, p := range result.WritePaths {
if !strings.HasPrefix(p, home+"/") {
t.Errorf("WritePaths returned path outside home: %q", p)
}
}
// Should not have duplicates in write paths
seen := make(map[string]bool)
for _, p := range result.WritePaths {
if seen[p] {
t.Errorf("WritePaths returned duplicate: %q", p)
}
seen[p] = true
}
// Should have the expected write paths
expectedWrites := map[string]bool{
filepath.Join(home, ".cache/testapp/db"): false,
filepath.Join(home, ".cache/testapp/ver"): false,
filepath.Join(home, ".config/testapp"): false,
}
for _, p := range result.WritePaths {
if _, ok := expectedWrites[p]; ok {
expectedWrites[p] = true
}
}
for p, found := range expectedWrites {
if !found {
t.Errorf("WritePaths missing expected path: %q, got: %v", p, result.WritePaths)
}
}
// Should have the expected read paths (only home paths, not /etc or /proc)
expectedRead := filepath.Join(home, ".config/testapp/conf.json")
foundRead := false
for _, p := range result.ReadPaths {
if p == expectedRead {
foundRead = true
}
if !strings.HasPrefix(p, home+"/") {
t.Errorf("ReadPaths returned path outside home: %q", p)
}
}
if !foundRead {
t.Errorf("ReadPaths missing expected path: %q, got: %v", expectedRead, result.ReadPaths)
}
}
func TestExtractReadPath(t *testing.T) {
tests := []struct {
name string
line string
expected string
}{
{
name: "openat with O_RDONLY",
line: `12345 openat(AT_FDCWD, "/home/user/.config/app/conf", O_RDONLY) = 3`,
expected: "/home/user/.config/app/conf",
},
{
name: "openat with write flags ignored",
line: `12345 openat(AT_FDCWD, "/home/user/file", O_WRONLY|O_CREAT, 0644) = 3`,
expected: "",
},
{
name: "non-openat ignored",
line: `12345 read(3, "data", 1024) = 5`,
expected: "",
},
{
name: "failed openat ignored",
line: `12345 openat(AT_FDCWD, "/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)`,
expected: "",
},
{
name: "directory open ignored",
line: `12345 openat(AT_FDCWD, "/home/user", O_RDONLY|O_DIRECTORY) = 3`,
expected: "",
},
{
name: "directory open with cloexec ignored",
line: `12345 openat(AT_FDCWD, "/home/user/.cache", O_RDONLY|O_CLOEXEC|O_DIRECTORY) = 4`,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractReadPath(tt.line)
if got != tt.expected {
t.Errorf("extractReadPath(%q) = %q, want %q", tt.line, got, tt.expected)
}
})
}
}
func TestCheckStraceAvailable(t *testing.T) {
// This test just verifies the function doesn't panic.
// The result depends on whether strace is installed on the test system.
err := CheckStraceAvailable()
if err != nil {
t.Logf("strace not available (expected in some CI environments): %v", err)
}
}

View File

@@ -0,0 +1,21 @@
//go:build !linux
package sandbox
import "fmt"
// StraceResult holds parsed read and write paths from an strace log.
type StraceResult struct {
WritePaths []string
ReadPaths []string
}
// CheckStraceAvailable returns an error on non-Linux platforms.
func CheckStraceAvailable() error {
return fmt.Errorf("learning mode is only available on Linux (requires strace and bubblewrap)")
}
// ParseStraceLog returns an error on non-Linux platforms.
func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
return nil, fmt.Errorf("strace log parsing is only available on Linux")
}

View File

@@ -0,0 +1,461 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSanitizeTemplateName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"opencode", "opencode"},
{"my-app", "my-app"},
{"my_app", "my_app"},
{"my.app", "my.app"},
{"my app", "my_app"},
{"/usr/bin/opencode", "usr_bin_opencode"},
{"my@app!v2", "my_app_v2"},
{"", "unknown"},
{"///", "unknown"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := SanitizeTemplateName(tt.input)
if got != tt.expected {
t.Errorf("SanitizeTemplateName(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestLearnedTemplatePath(t *testing.T) {
path := LearnedTemplatePath("opencode")
if !strings.HasSuffix(path, "/learned/opencode.json") {
t.Errorf("LearnedTemplatePath(\"opencode\") = %q, expected suffix /learned/opencode.json", path)
}
}
func TestFindApplicationDirectory(t *testing.T) {
home := "/home/testuser"
tests := []struct {
path string
expected string
}{
{"/home/testuser/.cache/opencode/db/main.sqlite", "/home/testuser/.cache/opencode"},
{"/home/testuser/.cache/opencode/version", "/home/testuser/.cache/opencode"},
{"/home/testuser/.config/opencode/settings.json", "/home/testuser/.config/opencode"},
{"/home/testuser/.local/share/myapp/data", "/home/testuser/.local/share/myapp"},
{"/home/testuser/.local/state/myapp/log", "/home/testuser/.local/state/myapp"},
// Not under a well-known parent
{"/home/testuser/documents/file.txt", ""},
{"/home/testuser/.cache", ""},
// Different home
{"/other/user/.cache/app/file", ""},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := findApplicationDirectory(tt.path, home)
if got != tt.expected {
t.Errorf("findApplicationDirectory(%q, %q) = %q, want %q", tt.path, home, got, tt.expected)
}
})
}
}
func TestCollapsePaths(t *testing.T) {
// Temporarily override home for testing
t.Setenv("HOME", "/home/testuser")
tests := []struct {
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",
paths: []string{
"/home/testuser/.cache/opencode/db/main.sqlite",
"/home/testuser/.cache/opencode/version",
},
contains: []string{"/home/testuser/.cache/opencode"},
},
{
name: "empty input",
paths: nil,
contains: nil,
},
{
name: "single path uses parent dir",
paths: []string{
"/home/testuser/.cache/opencode/version",
},
contains: []string{"/home/testuser/.cache/opencode"},
},
{
name: "paths from different app dirs",
paths: []string{
"/home/testuser/.cache/opencode/db",
"/home/testuser/.cache/opencode/version",
"/home/testuser/.config/opencode/settings.json",
},
contains: []string{
"/home/testuser/.cache/opencode",
"/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 {
t.Run(tt.name, func(t *testing.T) {
got := CollapsePaths(tt.paths)
if tt.contains == nil {
if got != nil {
t.Errorf("CollapsePaths() = %v, want nil", got)
}
return
}
for _, want := range tt.contains {
found := false
for _, g := range got {
if g == want {
found = true
break
}
}
if !found {
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)
}
}
}
})
}
}
func TestIsDefaultWritablePath(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"/dev/null", true},
{"/dev/stdout", true},
{"/tmp/somefile", false}, // /tmp is tmpfs inside sandbox, not host /tmp
{"/home/user/file", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := isDefaultWritablePath(tt.path)
if got != tt.expected {
t.Errorf("isDefaultWritablePath(%q) = %v, want %v", tt.path, got, tt.expected)
}
})
}
}
func TestIsSensitivePath(t *testing.T) {
home := "/home/testuser"
tests := []struct {
path string
expected bool
}{
{"/home/testuser/.bashrc", true},
{"/home/testuser/.gitconfig", true},
{"/home/testuser/.ssh/id_rsa", true},
{"/home/testuser/.ssh/known_hosts", true},
{"/home/testuser/.gnupg/secring.gpg", true},
{"/home/testuser/.env", true},
{"/home/testuser/project/.env.local", true},
{"/home/testuser/.cache/opencode/db", false},
{"/home/testuser/documents/readme.txt", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := isSensitivePath(tt.path, home)
if got != tt.expected {
t.Errorf("isSensitivePath(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
}
})
}
}
func TestDeduplicateSubPaths(t *testing.T) {
tests := []struct {
name string
paths []string
expected []string
}{
{
name: "removes sub-paths",
paths: []string{"/home/user/.cache", "/home/user/.cache/opencode"},
expected: []string{"/home/user/.cache"},
},
{
name: "keeps independent paths",
paths: []string{"/home/user/.cache/opencode", "/home/user/.config/opencode"},
expected: []string{"/home/user/.cache/opencode", "/home/user/.config/opencode"},
},
{
name: "empty",
paths: nil,
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := deduplicateSubPaths(tt.paths)
if len(got) != len(tt.expected) {
t.Errorf("deduplicateSubPaths(%v) = %v, want %v", tt.paths, got, tt.expected)
return
}
for i := range got {
if got[i] != tt.expected[i] {
t.Errorf("deduplicateSubPaths(%v)[%d] = %q, want %q", tt.paths, i, got[i], tt.expected[i])
}
}
})
}
}
func TestGetDangerousFilePatterns(t *testing.T) {
patterns := getDangerousFilePatterns()
if len(patterns) == 0 {
t.Error("getDangerousFilePatterns() returned empty list")
}
// Check some expected patterns
found := false
for _, p := range patterns {
if p == "~/.bashrc" {
found = true
break
}
}
if !found {
t.Error("getDangerousFilePatterns() missing ~/.bashrc")
}
}
func TestGetSensitiveReadPatterns(t *testing.T) {
patterns := getSensitiveReadPatterns()
if len(patterns) == 0 {
t.Error("getSensitiveReadPatterns() returned empty list")
}
found := false
for _, p := range patterns {
if p == "~/.ssh/id_*" {
found = true
break
}
}
if !found {
t.Error("getSensitiveReadPatterns() missing ~/.ssh/id_*")
}
}
func TestToTildePath(t *testing.T) {
tests := []struct {
path string
home string
expected string
}{
{"/home/user/.cache/opencode", "/home/user", "~/.cache/opencode"},
{"/other/path", "/home/user", "/other/path"},
{"/home/user/.cache/opencode", "", "/home/user/.cache/opencode"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := toTildePath(tt.path, tt.home)
if got != tt.expected {
t.Errorf("toTildePath(%q, %q) = %q, want %q", tt.path, tt.home, got, tt.expected)
}
})
}
}
func TestListLearnedTemplates(t *testing.T) {
// Use a temp dir to isolate from real user config
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Initially empty
templates, err := ListLearnedTemplates()
if err != nil {
t.Fatalf("ListLearnedTemplates() error: %v", err)
}
if len(templates) != 0 {
t.Errorf("expected empty list, got %v", templates)
}
// Create some templates
dir := LearnedTemplateDir()
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 {
t.Fatalf("ListLearnedTemplates() error: %v", err)
}
if len(templates) != 2 {
t.Errorf("expected 2 templates, got %d: %v", len(templates), templates)
}
names := make(map[string]bool)
for _, tmpl := range templates {
names[tmpl.Name] = true
}
if !names["opencode"] {
t.Error("missing template 'opencode'")
}
if !names["myapp"] {
t.Error("missing template 'myapp'")
}
}
func TestBuildTemplate(t *testing.T) {
allowRead := []string{"~/external-data"}
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
result := buildTemplate("opencode", allowRead, allowWrite)
// Check header comments
if !strings.Contains(result, `Learned template for "opencode"`) {
t.Error("template missing header comment with command name")
}
if !strings.Contains(result, "greywall --learning -- opencode") {
t.Error("template missing generation command")
}
if !strings.Contains(result, "Review and adjust paths as needed") {
t.Error("template missing review comment")
}
// 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")
}
if !strings.Contains(result, `"~/.cache/opencode"`) {
t.Error("template missing expected allowWrite path")
}
if !strings.Contains(result, `"denyWrite"`) {
t.Error("template missing denyWrite field")
}
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()
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Create a fake strace log
home, _ := os.UserHomeDir()
logContent := strings.Join([]string{
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db.sqlite") + `", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/version") + `", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 3`,
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 3`,
}, "\n")
logFile := filepath.Join(tmpDir, "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
templatePath, err := GenerateLearnedTemplate(logFile, "testapp", false)
if err != nil {
t.Fatalf("GenerateLearnedTemplate() error: %v", err)
}
if templatePath == "" {
t.Fatal("GenerateLearnedTemplate() returned empty path")
}
// Read and verify template
data, err := os.ReadFile(templatePath) //nolint:gosec // reading test-generated template file
if err != nil {
t.Fatalf("failed to read template: %v", err)
}
content := string(data)
if !strings.Contains(content, "testapp") {
t.Error("template doesn't contain command name")
}
if !strings.Contains(content, "allowWrite") {
t.Error("template doesn't contain allowWrite")
}
}

View File

@@ -20,19 +20,19 @@ import (
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
type ProxyBridge struct {
SocketPath string // Unix socket path
ProxyHost string // Parsed from ProxyURL
ProxyPort string // Parsed from ProxyURL
ProxyUser string // Username from ProxyURL (if any)
ProxyPass string // Password from ProxyURL (if any)
HasAuth bool // Whether credentials were provided
SocketPath string // Unix socket path
ProxyHost string // Parsed from ProxyURL
ProxyPort string // Parsed from ProxyURL
ProxyUser string // Username from ProxyURL (if any)
ProxyPass string // Password from ProxyURL (if any)
HasAuth bool // Whether credentials were provided
process *exec.Cmd
debug bool
}
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket.
// Inside the sandbox, a socat relay converts UDP DNS queries (port 53) to the Unix socket.
// On the host, socat forwards from the Unix socket to the actual DNS server (TCP).
// 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 {
@@ -122,6 +122,10 @@ type LinuxSandboxOptions struct {
Monitor bool
// Debug mode
Debug bool
// Learning mode: permissive sandbox with strace tracing
Learning bool
// Path to host-side strace log file (bind-mounted into sandbox)
StraceLogPath string
}
// NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
@@ -367,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"))
@@ -385,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) {
@@ -418,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
@@ -451,50 +664,46 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
}
}
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only
// and user-specified allowRead paths. Everything else is inaccessible.
// Learning mode: permissive sandbox with home + cwd writable
if opts.Learning {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
fmt.Fprintf(os.Stderr, "[greywall:linux] Learning mode: binding root read-only, home + cwd writable\n")
}
// Bind entire root read-only as baseline
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
// Make home and cwd writable (overrides read-only)
home, _ := os.UserHomeDir()
if home != "" && fileExists(home) {
bwrapArgs = append(bwrapArgs, "--bind", home, home)
}
if cwd != "" && fileExists(cwd) && cwd != home {
bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd)
}
// 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)
}
// 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)
}
// 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)
}
}
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
switch {
case opts.Learning:
// Skip defaultDenyRead logic in learning mode (already set up above)
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 - tmpfs root with selective mounts\n")
}
} 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", "/", "/")
}
@@ -507,6 +716,11 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// /tmp needs to be writable for many programs
bwrapArgs = append(bwrapArgs, "--tmpfs", "/tmp")
// Bind strace log file into sandbox AFTER --tmpfs /tmp so it's visible
if opts.Learning && opts.StraceLogPath != "" {
bwrapArgs = append(bwrapArgs, "--bind", opts.StraceLogPath, opts.StraceLogPath)
}
// Ensure /etc/resolv.conf is readable inside the sandbox.
// On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path
// on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't
@@ -560,112 +774,128 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
}
}
writablePaths := make(map[string]bool)
// In learning mode, skip writable paths, deny rules, and mandatory deny
// (the sandbox is already permissive with home + cwd writable)
if !opts.Learning {
// Add default write paths (system paths needed for operation)
for _, p := range GetDefaultWritePaths() {
// Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs)
if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
continue
}
writablePaths[p] = true
}
writablePaths := make(map[string]bool)
// Add user-specified allowWrite paths
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
for _, p := range expandedPaths {
// Add default write paths (system paths needed for operation)
for _, p := range GetDefaultWritePaths() {
// Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs)
if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
continue
}
writablePaths[p] = true
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) {
writablePaths[normalized] = true
// Add user-specified allowWrite paths
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
for _, p := range expandedPaths {
writablePaths[p] = true
}
}
}
// Make writable paths actually writable (override read-only root)
for p := range writablePaths {
if fileExists(p) {
bwrapArgs = append(bwrapArgs, "--bind", p, p)
}
}
// Handle denyRead paths - hide them
// For directories: use --tmpfs to replace with empty tmpfs
// For files: use --ro-bind /dev/null to mask with empty file
// Skip symlinks: they may point outside the sandbox and cause mount errors
if cfg != nil && cfg.Filesystem.DenyRead != nil {
expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead)
for _, p := range expandedDenyRead {
if canMountOver(p) {
if isDirectory(p) {
bwrapArgs = append(bwrapArgs, "--tmpfs", p)
} else {
// Mask file with /dev/null (appears as empty, unreadable)
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", p)
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) {
writablePaths[normalized] = true
}
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && canMountOver(normalized) {
if isDirectory(normalized) {
bwrapArgs = append(bwrapArgs, "--tmpfs", normalized)
} else {
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized)
// Make writable paths actually writable (override read-only root)
for p := range writablePaths {
if fileExists(p) {
bwrapArgs = append(bwrapArgs, "--bind", p, p)
}
}
// Handle denyRead paths - hide them
// For directories: use --tmpfs to replace with empty tmpfs
// For files: use --ro-bind /dev/null to mask with empty file
// Skip symlinks: they may point outside the sandbox and cause mount errors
if cfg != nil && cfg.Filesystem.DenyRead != nil {
expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead)
for _, p := range expandedDenyRead {
if canMountOver(p) {
if isDirectory(p) {
bwrapArgs = append(bwrapArgs, "--tmpfs", p)
} else {
// Mask file with /dev/null (appears as empty, unreadable)
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", p)
}
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && canMountOver(normalized) {
if isDirectory(normalized) {
bwrapArgs = append(bwrapArgs, "--tmpfs", normalized)
} else {
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized)
}
}
}
}
}
// Apply mandatory deny patterns (make dangerous files/dirs read-only)
// This overrides any writable mounts for these paths
//
// Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion.
// GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking
// the entire directory tree - this can hang on large directories (see issue #27).
//
// The concrete paths cover dangerous files in cwd and home directory. Files like
// .bashrc in subdirectories are not protected, but this may be lower-risk since shell
// rc files in project subdirectories are uncommon and not automatically sourced.
//
// TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect
// subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd)
// Apply mandatory deny patterns (make dangerous files/dirs read-only)
// This overrides any writable mounts for these paths
//
// Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion.
// GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking
// the entire directory tree - this can hang on large directories (see issue #27).
//
// The concrete paths cover dangerous files in cwd and home directory. Files like
// .bashrc in subdirectories are not protected, but this may be lower-risk since shell
// rc files in project subdirectories are uncommon and not automatically sourced.
//
// TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect
// subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd)
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
// 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
}
}
}
// Handle explicit denyWrite paths (make them read-only)
if cfg != nil && cfg.Filesystem.DenyWrite != nil {
expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite)
for _, p := range expandedDenyWrite {
if fileExists(p) && !seen[p] {
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) && !maskedPaths[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] {
seen[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
// Handle explicit denyWrite paths (make them read-only)
if cfg != nil && cfg.Filesystem.DenyWrite != nil {
expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite)
for _, p := range expandedDenyWrite {
if fileExists(p) && !seen[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] {
seen[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
}
}
} // end if !opts.Learning
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
var dnsRelayResolvConf string // temp file path for custom resolv.conf
@@ -693,21 +923,29 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
)
}
// Override /etc/resolv.conf to point DNS at our local relay (port 53).
// Inside the sandbox, a socat relay on UDP :53 converts queries to the
// DNS bridge (Unix socket -> host DNS server) or to TCP through the tunnel.
// Override /etc/resolv.conf for DNS resolution inside the sandbox.
if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) {
tmpResolv, err := os.CreateTemp("", "greywall-resolv-*.conf")
if err == nil {
_, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n")
tmpResolv.Close()
if dnsBridge != nil {
// DNS bridge: point at local socat relay (UDP :53 -> Unix socket -> host DNS server)
_, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n")
} else {
// tun2socks: point at public DNS with TCP mode.
// tun2socks intercepts TCP traffic and forwards through the SOCKS5 proxy,
// but doesn't reliably handle UDP DNS. "options use-vc" forces the resolver
// to use TCP (RFC 1035 §4.2.2), which tun2socks handles natively.
// 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()
dnsRelayResolvConf = tmpResolv.Name()
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
if opts.Debug {
if dnsBridge != nil {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr)
} else {
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 127.0.0.1 (TCP relay through tunnel)\n")
fmt.Fprintf(os.Stderr, "[greywall:linux] DNS: overriding resolv.conf -> 1.1.1.1 (TCP via tun2socks tunnel)\n")
}
}
}
@@ -738,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
@@ -778,7 +1024,9 @@ TUN2SOCKS_PID=$!
`, proxyBridge.SocketPath, tun2socksProxyURL))
// DNS relay: convert UDP DNS queries on port 53 so apps can resolve names.
// DNS relay: only needed when using a dedicated DNS bridge.
// When using tun2socks without a DNS bridge, resolv.conf is configured with
// "options use-vc" to force TCP DNS, which tun2socks handles natively.
if dnsBridge != nil {
// Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server
innerScript.WriteString(fmt.Sprintf(`# DNS relay: UDP queries -> Unix socket -> host DNS server (%s)
@@ -786,13 +1034,6 @@ socat UDP4-RECVFROM:53,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
DNS_RELAY_PID=$!
`, dnsBridge.DnsAddr, dnsBridge.SocketPath))
} else {
// Fallback: UDP :53 -> TCP to public DNS through the tunnel
innerScript.WriteString(`# DNS relay: UDP queries -> TCP 1.1.1.1:53 (through tun2socks tunnel)
socat UDP4-RECVFROM:53,fork,reuseaddr TCP:1.1.1.1:53 >/dev/null 2>&1 &
DNS_RELAY_PID=$!
`)
}
} else if proxyBridge != nil {
// Fallback: no TUN support, use env-var-based proxying
@@ -846,8 +1087,28 @@ sleep 0.3
# Run the user command
`)
// Use Landlock wrapper if available
if useLandlockWrapper {
// In learning mode, wrap the command with strace to trace syscalls.
// 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.
kill -TERM -1 2>/dev/null
sleep 0.1
exit $GREYWALL_STRACE_EXIT
`,
ShellQuoteSingle(opts.StraceLogPath), command,
))
case useLandlockWrapper:
// Use Landlock wrapper if available
// Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied
if cfg != nil {
@@ -867,7 +1128,7 @@ sleep 0.3
// 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")
}
@@ -897,6 +1158,9 @@ sleep 0.3
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports))
}
if opts.Learning {
featureList = append(featureList, "learning(strace)")
}
fmt.Fprintf(os.Stderr, "[greywall:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
}

View File

@@ -36,9 +36,9 @@ type LinuxFeatures struct {
CanUnshareNet bool
// Transparent proxy support
HasIpCommand bool // ip (iproute2) available
HasDevNetTun bool // /dev/net/tun exists
HasTun2Socks bool // tun2socks embedded binary available
HasIpCommand bool // ip (iproute2) available
HasDevNetTun bool // /dev/net/tun exists
HasTun2Socks bool // tun2socks embedded binary available
// Kernel version
KernelMajor int
@@ -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

@@ -29,11 +29,13 @@ type ReverseBridge struct {
// LinuxSandboxOptions is a stub for non-Linux platforms.
type LinuxSandboxOptions struct {
UseLandlock bool
UseSeccomp bool
UseEBPF bool
Monitor bool
Debug bool
UseLandlock bool
UseSeccomp bool
UseEBPF bool
Monitor bool
Debug bool
Learning bool
StraceLogPath string
}
// NewProxyBridge returns an error on non-Linux platforms.

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"
@@ -11,11 +12,11 @@ import (
// the macOS sandbox profile allows outbound to the proxy host:port.
func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
tests := []struct {
name string
proxyURL string
wantProxy bool
proxyHost string
proxyPort string
name string
proxyURL string
wantProxy bool
proxyHost string
proxyPort string
}{
{
name: "no proxy - network blocked",
@@ -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

@@ -19,6 +19,9 @@ type Manager struct {
debug bool
monitor bool
initialized bool
learning bool // learning mode: permissive sandbox with strace
straceLogPath string // host-side temp file for strace output
commandName string // name of the command being learned
}
// NewManager creates a new sandbox manager.
@@ -35,6 +38,21 @@ func (m *Manager) SetExposedPorts(ports []int) {
m.exposedPorts = ports
}
// SetLearning enables or disables learning mode.
func (m *Manager) SetLearning(enabled bool) {
m.learning = enabled
}
// SetCommandName sets the command name for learning mode template generation.
func (m *Manager) SetCommandName(name string) {
m.commandName = name
}
// IsLearning returns whether learning mode is enabled.
func (m *Manager) IsLearning() bool {
return m.learning
}
// Initialize sets up the sandbox infrastructure.
func (m *Manager) Initialize() error {
if m.initialized {
@@ -60,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)
}
@@ -72,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)
}
@@ -90,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)
}
@@ -102,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)")
}
@@ -128,12 +150,55 @@ func (m *Manager) WrapCommand(command string) (string, error) {
case platform.MacOS:
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
case platform.Linux:
if m.learning {
return m.wrapCommandLearning(command)
}
return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug)
default:
return "", fmt.Errorf("unsupported platform: %s", plat)
}
}
// wrapCommandLearning creates a permissive sandbox with strace for learning mode.
func (m *Manager) wrapCommandLearning(command string) (string, error) {
// Create host-side temp file for strace output
tmpFile, err := os.CreateTemp("", "greywall-strace-*.log")
if err != nil {
return "", fmt.Errorf("failed to create strace log file: %w", err)
}
_ = tmpFile.Close()
m.straceLogPath = tmpFile.Name()
m.logDebug("Strace log file: %s", m.straceLogPath)
return WrapCommandLinuxWithOptions(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, LinuxSandboxOptions{
UseLandlock: false, // Disabled: seccomp blocks ptrace which strace needs
UseSeccomp: false, // Disabled: conflicts with strace
UseEBPF: false,
Debug: m.debug,
Learning: true,
StraceLogPath: m.straceLogPath,
})
}
// GenerateLearnedTemplate generates a config template from the strace log collected during learning.
func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
if m.straceLogPath == "" {
return "", fmt.Errorf("no strace log available (was learning mode enabled?)")
}
templatePath, err := GenerateLearnedTemplate(m.straceLogPath, cmdName, m.debug)
if err != nil {
return "", err
}
// Clean up strace log since we've processed it
_ = os.Remove(m.straceLogPath)
m.straceLogPath = ""
return templatePath, nil
}
// Cleanup stops the proxies and cleans up resources.
func (m *Manager) Cleanup() {
if m.reverseBridge != nil {
@@ -146,7 +211,11 @@ 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)
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

@@ -102,4 +102,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
}
return string(data), nil
}

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"