Compare commits
10 Commits
feat-isola
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a5c98328 | |||
| 5145016c4e | |||
| 62bf37d481 | |||
| ed6517cc24 | |||
| 2061dfe63b | |||
| 5aeb9c86c0 | |||
| 626eaa1895 | |||
| 18c18ec3a8 | |||
| f4c9422f77 | |||
| c19370f8b3 |
101
.gitea/workflows/benchmark.yml
Normal file
101
.gitea/workflows/benchmark.yml
Normal 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
115
.gitea/workflows/main.yml
Normal 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
|
||||||
62
.gitea/workflows/release.yml
Normal file
62
.gitea/workflows/release.yml
Normal 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
|
||||||
@@ -1,40 +1,49 @@
|
|||||||
|
version: "2"
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
|
||||||
modules-download-mode: readonly
|
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:
|
linters:
|
||||||
enable-all: false
|
default: none
|
||||||
disable-all: true
|
|
||||||
enable:
|
enable:
|
||||||
- staticcheck
|
|
||||||
- errcheck
|
- errcheck
|
||||||
- gosimple
|
|
||||||
- govet
|
|
||||||
- unused
|
|
||||||
- ineffassign
|
|
||||||
- gosec
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- revive
|
- gosec
|
||||||
- gofumpt
|
- govet
|
||||||
|
- ineffassign
|
||||||
- misspell
|
- misspell
|
||||||
|
- revive
|
||||||
issues:
|
- staticcheck
|
||||||
exclude-use-default: false
|
- 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$
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
|
gitea_urls:
|
||||||
|
api: https://gitea.app.monadical.io/api/v1
|
||||||
|
download: https://gitea.app.monadical.io
|
||||||
|
skip_tls_verify: false
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
@@ -42,7 +47,7 @@ checksum:
|
|||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
use: github
|
use: gitea
|
||||||
format: "{{ .SHA }}: {{ .Message }}{{ with .AuthorUsername }} (@{{ . }}){{ end }}"
|
format: "{{ .SHA }}: {{ .Message }}{{ with .AuthorUsername }} (@{{ . }}){{ end }}"
|
||||||
filters:
|
filters:
|
||||||
exclude:
|
exclude:
|
||||||
@@ -76,7 +81,7 @@ changelog:
|
|||||||
order: 9999
|
order: 9999
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
gitea:
|
||||||
owner: monadical
|
owner: monadical
|
||||||
name: greywall
|
name: greywall
|
||||||
draft: false
|
draft: false
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ scripts/ Smoke tests, benchmarks, release
|
|||||||
|
|
||||||
- **Language:** Go 1.25+
|
- **Language:** Go 1.25+
|
||||||
- **Formatter:** `gofumpt` (enforced in CI)
|
- **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`)
|
- **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
|
- **Platform code:** build tags (`//go:build linux`, `//go:build darwin`) with `*_stub.go` for unsupported platforms
|
||||||
- **Error handling:** custom error types (e.g., `CommandBlockedError`)
|
- **Error handling:** custom error types (e.g., `CommandBlockedError`)
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -70,7 +70,7 @@ build-darwin:
|
|||||||
install-lint-tools:
|
install-lint-tools:
|
||||||
@echo "Installing linting tools..."
|
@echo "Installing linting tools..."
|
||||||
go install mvdan.cc/gofumpt@latest
|
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"
|
@echo "Linting tools installed"
|
||||||
|
|
||||||
setup: deps install-lint-tools
|
setup: deps install-lint-tools
|
||||||
|
|||||||
124
README.md
124
README.md
@@ -2,20 +2,20 @@
|
|||||||
|
|
||||||
**The sandboxing layer of the GreyHaven platform.**
|
**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
|
```bash
|
||||||
# Block all network access (default)
|
# Block all network access (default — no proxy running = no connectivity)
|
||||||
greywall curl https://example.com # → 403 Forbidden
|
greywall -- curl https://example.com
|
||||||
|
|
||||||
# Allow specific domains
|
# Route traffic through an external SOCKS5 proxy
|
||||||
greywall -t code npm install # → uses 'code' template with npm/pypi/etc allowed
|
greywall --proxy socks5://localhost:1080 -- curl https://example.com
|
||||||
|
|
||||||
# Block dangerous commands
|
# Block dangerous commands
|
||||||
greywall -c "rm -rf /" # → blocked by command deny rules
|
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
|
## Install
|
||||||
|
|
||||||
@@ -39,83 +39,123 @@ go install gitea.app.monadical.io/monadical/greywall/cmd/greywall@latest
|
|||||||
```bash
|
```bash
|
||||||
git clone https://gitea.app.monadical.io/monadical/greywall
|
git clone https://gitea.app.monadical.io/monadical/greywall
|
||||||
cd greywall
|
cd greywall
|
||||||
go build -o greywall ./cmd/greywall
|
make setup && make build
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Additional requirements for Linux:**
|
**Linux dependencies:**
|
||||||
|
|
||||||
- `bubblewrap` (for sandboxing)
|
- `bubblewrap` — container-free sandboxing (required)
|
||||||
- `socat` (for network bridging)
|
- `socat` — network bridging (required)
|
||||||
- `bpftrace` (optional, for filesystem violation visibility when monitoring with `-m`)
|
|
||||||
|
Check dependency status with `greywall --version`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic
|
### Basic commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run command with all network blocked (no domains allowed by default)
|
# Run with all network blocked (default)
|
||||||
greywall curl https://example.com
|
greywall -- curl https://example.com
|
||||||
|
|
||||||
# Run with shell expansion
|
# Run with shell expansion
|
||||||
greywall -c "echo hello && ls"
|
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
|
# Enable debug logging
|
||||||
greywall -d curl https://example.com
|
greywall -d -- curl https://example.com
|
||||||
|
|
||||||
# Use a template
|
# Monitor sandbox violations
|
||||||
greywall -t code -- claude # Runs Claude Code using `code` template config
|
greywall -m -- npm install
|
||||||
|
|
||||||
# Monitor mode (shows violations)
|
# Show available Linux security features
|
||||||
greywall -m npm install
|
greywall --linux-features
|
||||||
|
|
||||||
# Show all commands and options
|
# Show version and dependency status
|
||||||
greywall --help
|
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
|
### Configuration
|
||||||
|
|
||||||
Greywall reads from `~/.config/greywall/greywall.json` by default (or `~/Library/Application Support/greywall/greywall.json` on macOS).
|
Greywall reads from `~/.config/greywall/greywall.json` by default (or `~/Library/Application Support/greywall/greywall.json` on macOS).
|
||||||
|
|
||||||
```json
|
```jsonc
|
||||||
{
|
{
|
||||||
"extends": "code",
|
// Route traffic through an external SOCKS5 proxy
|
||||||
"network": { "allowedDomains": ["private.company.com"] },
|
"network": {
|
||||||
"filesystem": { "allowWrite": ["."] },
|
"proxyUrl": "socks5://localhost:1080",
|
||||||
"command": { "deny": ["git push", "npm publish"] }
|
"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
|
By default, traffic routes through the GreyProxy SOCKS5 proxy at `localhost:43052` with DNS via `localhost:43053`.
|
||||||
|
|
||||||
```bash
|
|
||||||
greywall import --claude --save
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Network isolation** - All outbound blocked by default; allowlist domains via config
|
- **Transparent proxy** — All TCP/UDP traffic captured at the kernel level via tun2socks and routed through an external SOCKS5 proxy (Linux)
|
||||||
- **Filesystem restrictions** - Control read/write access paths
|
- **Network isolation** — All outbound blocked by default; traffic only flows when a proxy is available
|
||||||
- **Command blocking** - Deny dangerous commands like `rm -rf /`, `git push`
|
- **Filesystem restrictions** — Deny-by-default read mode, controlled write paths, sensitive file protection
|
||||||
- **SSH Command Filtering** - Control which hosts and commands are allowed over SSH
|
- **Learning mode** — Trace filesystem access with strace and auto-generate config templates
|
||||||
- **Built-in templates** - Pre-configured rulesets for common workflows
|
- **Command blocking** — Deny dangerous commands (`rm -rf /`, `git push`, `shutdown`, etc.)
|
||||||
- **Violation monitoring** - Real-time logging of blocked requests (`-m`)
|
- **SSH filtering** — Control which hosts and commands are allowed over SSH
|
||||||
- **Cross-platform** - macOS (sandbox-exec) + Linux (bubblewrap)
|
- **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
|
## Documentation
|
||||||
|
|
||||||
- [Index](/docs/README.md)
|
- [Documentation Index](docs/README.md)
|
||||||
- [Quickstart Guide](docs/quickstart.md)
|
- [Quickstart Guide](docs/quickstart.md)
|
||||||
|
- [Why Greywall](docs/why-greywall.md)
|
||||||
- [Configuration Reference](docs/configuration.md)
|
- [Configuration Reference](docs/configuration.md)
|
||||||
- [Security Model](docs/security-model.md)
|
- [Security Model](docs/security-model.md)
|
||||||
- [Architecture](ARCHITECTURE.md)
|
- [Architecture](ARCHITECTURE.md)
|
||||||
|
- [Linux Security Features](docs/linux-security-features.md)
|
||||||
|
- [AI Agent Integration](docs/agents.md)
|
||||||
- [Library Usage (Go)](docs/library.md)
|
- [Library Usage (Go)](docs/library.md)
|
||||||
- [Examples](examples/)
|
- [Troubleshooting](docs/troubleshooting.md)
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
"gitea.app.monadical.io/monadical/greywall/internal/config"
|
||||||
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
"gitea.app.monadical.io/monadical/greywall/internal/platform"
|
||||||
|
"gitea.app.monadical.io/monadical/greywall/internal/proxy"
|
||||||
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
|
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -55,8 +56,8 @@ func main() {
|
|||||||
Long: `greywall is a command-line tool that runs commands in a sandboxed environment
|
Long: `greywall is a command-line tool that runs commands in a sandboxed environment
|
||||||
with network and filesystem restrictions.
|
with network and filesystem restrictions.
|
||||||
|
|
||||||
By default, traffic is routed through the GreyHaven SOCKS5 proxy at localhost:42051
|
By default, traffic is routed through the GreyProxy SOCKS5 proxy at localhost:43052
|
||||||
with DNS via localhost:42053. Use --proxy and --dns to override, or configure in
|
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).
|
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
|
On Linux, greywall uses tun2socks for truly transparent proxying: all TCP/UDP traffic
|
||||||
@@ -98,8 +99,8 @@ Configuration file format:
|
|||||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
|
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().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
|
||||||
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
|
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:42053)")
|
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().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().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().BoolVarP(&showVersion, "version", "v", false, "Show version information")
|
||||||
@@ -111,6 +112,8 @@ Configuration file format:
|
|||||||
|
|
||||||
rootCmd.AddCommand(newCompletionCmd(rootCmd))
|
rootCmd.AddCommand(newCompletionCmd(rootCmd))
|
||||||
rootCmd.AddCommand(newTemplatesCmd())
|
rootCmd.AddCommand(newTemplatesCmd())
|
||||||
|
rootCmd.AddCommand(newCheckCmd())
|
||||||
|
rootCmd.AddCommand(newSetupCmd())
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -121,11 +124,7 @@ Configuration file format:
|
|||||||
|
|
||||||
func runCommand(cmd *cobra.Command, args []string) error {
|
func runCommand(cmd *cobra.Command, args []string) error {
|
||||||
if showVersion {
|
if showVersion {
|
||||||
fmt.Printf("greywall - lightweight, container-free sandbox for running untrusted commands\n")
|
fmt.Printf("greywall %s\n", version)
|
||||||
fmt.Printf(" Version: %s\n", version)
|
|
||||||
fmt.Printf(" Built: %s\n", buildTime)
|
|
||||||
fmt.Printf(" Commit: %s\n", gitCommit)
|
|
||||||
sandbox.PrintDependencyStatus()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,19 +202,20 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
if templatePath != "" {
|
if templatePath != "" {
|
||||||
learnedCfg, loadErr := config.Load(templatePath)
|
learnedCfg, loadErr := config.Load(templatePath)
|
||||||
if loadErr != nil {
|
switch {
|
||||||
|
case loadErr != nil:
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template: %v\n", loadErr)
|
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template: %v\n", loadErr)
|
||||||
}
|
}
|
||||||
} else if learnedCfg != nil {
|
case learnedCfg != nil:
|
||||||
cfg = config.Merge(cfg, learnedCfg)
|
cfg = config.Merge(cfg, learnedCfg)
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel)
|
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel)
|
||||||
}
|
}
|
||||||
} else if templateName != "" {
|
case templateName != "":
|
||||||
// Explicit --template but file doesn't exist
|
// Explicit --template but file doesn't exist
|
||||||
return fmt.Errorf("learned template %q not found at %s\nRun: greywall templates list", templateName, templatePath)
|
return fmt.Errorf("learned template %q not found at %s\nRun: greywall templates list", templateName, templatePath)
|
||||||
} else if cmdName != "" {
|
case cmdName != "":
|
||||||
// No template found for this command - suggest creating one
|
// 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)
|
fmt.Fprintf(os.Stderr, "[greywall] No learned template for %q. Run with --learning to create one.\n", cmdName)
|
||||||
}
|
}
|
||||||
@@ -230,18 +230,18 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
cfg.Network.DnsAddr = dnsAddr
|
cfg.Network.DnsAddr = dnsAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
// GreyHaven defaults: when no proxy or DNS is configured (neither via CLI
|
// GreyProxy defaults: when no proxy or DNS is configured (neither via CLI
|
||||||
// nor config file), use the standard GreyHaven infrastructure ports.
|
// nor config file), use the standard GreyProxy ports.
|
||||||
if cfg.Network.ProxyURL == "" {
|
if cfg.Network.ProxyURL == "" {
|
||||||
cfg.Network.ProxyURL = "socks5://localhost:42052"
|
cfg.Network.ProxyURL = "socks5://localhost:43052"
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:42052\n")
|
fmt.Fprintf(os.Stderr, "[greywall] Defaulting proxy to socks5://localhost:43052\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.Network.DnsAddr == "" {
|
if cfg.Network.DnsAddr == "" {
|
||||||
cfg.Network.DnsAddr = "localhost:42053"
|
cfg.Network.DnsAddr = "localhost:43053"
|
||||||
if debug {
|
if debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall] Defaulting DNS to localhost:42053\n")
|
fmt.Fprintf(os.Stderr, "[greywall] Defaulting DNS to localhost:43053\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +411,89 @@ func extractCommandName(args []string, cmdStr string) string {
|
|||||||
return filepath.Base(name)
|
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.
|
// newCompletionCmd creates the completion subcommand for shell completions.
|
||||||
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
|
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -503,7 +586,7 @@ Examples:
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
name := args[0]
|
name := args[0]
|
||||||
templatePath := sandbox.LearnedTemplatePath(name)
|
templatePath := sandbox.LearnedTemplatePath(name)
|
||||||
data, err := os.ReadFile(templatePath)
|
data, err := os.ReadFile(templatePath) //nolint:gosec // user-specified template path - intentional
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("template %q not found\nRun: greywall templates list", name)
|
return fmt.Errorf("template %q not found\nRun: greywall templates list", name)
|
||||||
|
|||||||
@@ -93,3 +93,29 @@ echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/9
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Alternative:** Accept the limitation — greywall still works for filesystem sandboxing, seccomp, and Landlock. Network access is blocked outright rather than redirected through a proxy.
|
**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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type NetworkConfig struct {
|
|||||||
|
|
||||||
// FilesystemConfig defines filesystem restrictions.
|
// FilesystemConfig defines filesystem restrictions.
|
||||||
type FilesystemConfig struct {
|
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)
|
AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
|
||||||
DenyRead []string `json:"denyRead"`
|
DenyRead []string `json:"denyRead"`
|
||||||
AllowWrite []string `json:"allowWrite"`
|
AllowWrite []string `json:"allowWrite"`
|
||||||
@@ -44,6 +44,12 @@ type FilesystemConfig struct {
|
|||||||
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
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.
|
// CommandConfig defines command restrictions.
|
||||||
type CommandConfig struct {
|
type CommandConfig struct {
|
||||||
Deny []string `json:"deny"`
|
Deny []string `json:"deny"`
|
||||||
@@ -417,8 +423,8 @@ func Merge(base, override *Config) *Config {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
// Boolean fields: true if either enables it
|
// Pointer field: override wins if set, otherwise base (nil = deny-by-default)
|
||||||
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead,
|
DefaultDenyRead: mergeOptionalBool(base.Filesystem.DefaultDenyRead, override.Filesystem.DefaultDenyRead),
|
||||||
|
|
||||||
// Append slices
|
// Append slices
|
||||||
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),
|
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ func TestMerge(t *testing.T) {
|
|||||||
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
|
t.Run("merge defaultDenyRead and allowRead", func(t *testing.T) {
|
||||||
base := &Config{
|
base := &Config{
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
DefaultDenyRead: true,
|
DefaultDenyRead: boolPtr(true),
|
||||||
AllowRead: []string{"/home/user/project"},
|
AllowRead: []string{"/home/user/project"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -421,13 +421,40 @@ func TestMerge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
result := Merge(base, override)
|
result := Merge(base, override)
|
||||||
|
|
||||||
if !result.Filesystem.DefaultDenyRead {
|
if !result.Filesystem.IsDefaultDenyRead() {
|
||||||
t.Error("expected DefaultDenyRead to be true (from base)")
|
t.Error("expected IsDefaultDenyRead() to be true (from base)")
|
||||||
}
|
}
|
||||||
if len(result.Filesystem.AllowRead) != 2 {
|
if len(result.Filesystem.AllowRead) != 2 {
|
||||||
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
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 {
|
func boolPtr(b bool) *bool {
|
||||||
|
|||||||
126
internal/proxy/detect.go
Normal file
126
internal/proxy/detect.go
Normal 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
258
internal/proxy/install.go
Normal 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
30
internal/proxy/start.go
Normal 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
|
||||||
|
}
|
||||||
@@ -28,6 +28,30 @@ var DangerousDirectories = []string{
|
|||||||
".claude/agents",
|
".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.
|
// GetDefaultWritePaths returns system paths that should be writable for commands to work.
|
||||||
func GetDefaultWritePaths() []string {
|
func GetDefaultWritePaths() []string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|||||||
@@ -123,13 +123,17 @@ func assertContains(t *testing.T, haystack, needle string) {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// testConfig creates a test configuration with sensible defaults.
|
// 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 {
|
func testConfig() *config.Config {
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Network: config.NetworkConfig{},
|
Network: config.NetworkConfig{},
|
||||||
Filesystem: config.FilesystemConfig{
|
Filesystem: config.FilesystemConfig{
|
||||||
DenyRead: []string{},
|
DefaultDenyRead: boolPtr(false), // Legacy mode for existing tests
|
||||||
AllowWrite: []string{},
|
DenyRead: []string{},
|
||||||
DenyWrite: []string{},
|
AllowWrite: []string{},
|
||||||
|
DenyWrite: []string{},
|
||||||
},
|
},
|
||||||
Command: config.CommandConfig{
|
Command: config.CommandConfig{
|
||||||
Deny: []string{},
|
Deny: []string{},
|
||||||
|
|||||||
@@ -87,6 +87,43 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
|
|||||||
allowWrite = append(allowWrite, toTildePath(p, home))
|
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
|
// Convert read paths to tilde-relative for display
|
||||||
var readDisplay []string
|
var readDisplay []string
|
||||||
for _, p := range result.ReadPaths {
|
for _, p := range result.ReadPaths {
|
||||||
@@ -103,6 +140,13 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(allowRead) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "[greywall] Additional read paths (beyond system + CWD):\n")
|
||||||
|
for _, p := range allowRead {
|
||||||
|
fmt.Fprintf(os.Stderr, "[greywall] %s\n", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(allowWrite) > 1 { // >1 because "." is always included
|
if len(allowWrite) > 1 { // >1 because "." is always included
|
||||||
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
|
fmt.Fprintf(os.Stderr, "[greywall] Discovered write paths (collapsed):\n")
|
||||||
for _, p := range allowWrite {
|
for _, p := range allowWrite {
|
||||||
@@ -118,15 +162,15 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
|
|||||||
fmt.Fprintf(os.Stderr, "\n")
|
fmt.Fprintf(os.Stderr, "\n")
|
||||||
|
|
||||||
// Build template
|
// Build template
|
||||||
template := buildTemplate(cmdName, allowWrite)
|
template := buildTemplate(cmdName, allowRead, allowWrite)
|
||||||
|
|
||||||
// Save template
|
// Save template
|
||||||
templatePath := LearnedTemplatePath(cmdName)
|
templatePath := LearnedTemplatePath(cmdName)
|
||||||
if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(templatePath), 0o750); err != nil {
|
||||||
return "", fmt.Errorf("failed to create template directory: %w", err)
|
return "", fmt.Errorf("failed to create template directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil {
|
if err := os.WriteFile(templatePath, []byte(template), 0o600); err != nil {
|
||||||
return "", fmt.Errorf("failed to write template: %w", err)
|
return "", fmt.Errorf("failed to write template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +220,15 @@ func CollapsePaths(paths []string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For standalone paths, use their parent directory
|
// For standalone paths, use their parent directory — but never collapse to $HOME
|
||||||
for _, p := range standalone {
|
for _, p := range standalone {
|
||||||
result = append(result, filepath.Dir(p))
|
parent := filepath.Dir(p)
|
||||||
|
if parent == home {
|
||||||
|
// Keep exact file path to avoid opening entire home directory
|
||||||
|
result = append(result, p)
|
||||||
|
} else {
|
||||||
|
result = append(result, parent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort and deduplicate (remove sub-paths of other paths)
|
// Sort and deduplicate (remove sub-paths of other paths)
|
||||||
@@ -255,11 +305,7 @@ func isSensitivePath(path, home string) bool {
|
|||||||
|
|
||||||
// Check GPG
|
// Check GPG
|
||||||
gnupgDir := filepath.Join(home, ".gnupg")
|
gnupgDir := filepath.Join(home, ".gnupg")
|
||||||
if strings.HasPrefix(path, gnupgDir+"/") {
|
return strings.HasPrefix(path, gnupgDir+"/")
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDangerousFilePatterns returns denyWrite entries for DangerousFiles.
|
// getDangerousFilePatterns returns denyWrite entries for DangerousFiles.
|
||||||
@@ -345,9 +391,18 @@ func deduplicateSubPaths(paths []string) []string {
|
|||||||
return result
|
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.
|
// buildTemplate generates the JSONC template content for a learned config.
|
||||||
func buildTemplate(cmdName string, allowWrite []string) string {
|
func buildTemplate(cmdName string, allowRead, allowWrite []string) string {
|
||||||
type fsConfig struct {
|
type fsConfig struct {
|
||||||
|
AllowRead []string `json:"allowRead,omitempty"`
|
||||||
AllowWrite []string `json:"allowWrite"`
|
AllowWrite []string `json:"allowWrite"`
|
||||||
DenyWrite []string `json:"denyWrite"`
|
DenyWrite []string `json:"denyWrite"`
|
||||||
DenyRead []string `json:"denyRead"`
|
DenyRead []string `json:"denyRead"`
|
||||||
@@ -356,11 +411,15 @@ func buildTemplate(cmdName string, allowWrite []string) string {
|
|||||||
Filesystem fsConfig `json:"filesystem"`
|
Filesystem fsConfig `json:"filesystem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Combine sensitive read patterns with .env project patterns
|
||||||
|
denyRead := append(getSensitiveReadPatterns(), getSensitiveProjectDenyPatterns()...)
|
||||||
|
|
||||||
cfg := templateConfig{
|
cfg := templateConfig{
|
||||||
Filesystem: fsConfig{
|
Filesystem: fsConfig{
|
||||||
|
AllowRead: allowRead,
|
||||||
AllowWrite: allowWrite,
|
AllowWrite: allowWrite,
|
||||||
DenyWrite: getDangerousFilePatterns(),
|
DenyWrite: getDangerousFilePatterns(),
|
||||||
DenyRead: getSensitiveReadPatterns(),
|
DenyRead: denyRead,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func ParseStraceLog(logPath string, debug bool) (*StraceResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open strace log: %w", err)
|
return nil, fmt.Errorf("failed to open strace log: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
seenWrite := make(map[string]bool)
|
seenWrite := make(map[string]bool)
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func TestParseStraceLog(t *testing.T) {
|
|||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
logFile := filepath.Join(t.TempDir(), "strace.log")
|
logFile := filepath.Join(t.TempDir(), "strace.log")
|
||||||
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
|
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,14 +70,13 @@ func TestFindApplicationDirectory(t *testing.T) {
|
|||||||
|
|
||||||
func TestCollapsePaths(t *testing.T) {
|
func TestCollapsePaths(t *testing.T) {
|
||||||
// Temporarily override home for testing
|
// Temporarily override home for testing
|
||||||
origHome := os.Getenv("HOME")
|
t.Setenv("HOME", "/home/testuser")
|
||||||
os.Setenv("HOME", "/home/testuser")
|
|
||||||
defer os.Setenv("HOME", origHome)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
paths []string
|
paths []string
|
||||||
contains []string // paths that should be in the result
|
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",
|
name: "multiple paths under same app dir",
|
||||||
@@ -111,6 +110,33 @@ func TestCollapsePaths(t *testing.T) {
|
|||||||
"/home/testuser/.config/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 {
|
for _, tt := range tests {
|
||||||
@@ -134,6 +160,13 @@ func TestCollapsePaths(t *testing.T) {
|
|||||||
t.Errorf("CollapsePaths() = %v, missing expected path %q", got, want)
|
t.Errorf("CollapsePaths() = %v, missing expected path %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, bad := range tt.notContains {
|
||||||
|
for _, g := range got {
|
||||||
|
if g == bad {
|
||||||
|
t.Errorf("CollapsePaths() = %v, should NOT contain %q", got, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,9 +317,7 @@ func TestToTildePath(t *testing.T) {
|
|||||||
func TestListLearnedTemplates(t *testing.T) {
|
func TestListLearnedTemplates(t *testing.T) {
|
||||||
// Use a temp dir to isolate from real user config
|
// Use a temp dir to isolate from real user config
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||||
os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
|
||||||
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
|
|
||||||
|
|
||||||
// Initially empty
|
// Initially empty
|
||||||
templates, err := ListLearnedTemplates()
|
templates, err := ListLearnedTemplates()
|
||||||
@@ -299,10 +330,18 @@ func TestListLearnedTemplates(t *testing.T) {
|
|||||||
|
|
||||||
// Create some templates
|
// Create some templates
|
||||||
dir := LearnedTemplateDir()
|
dir := LearnedTemplateDir()
|
||||||
os.MkdirAll(dir, 0o755)
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o644)
|
t.Fatal(err)
|
||||||
os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o644)
|
}
|
||||||
os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o644) // should be ignored
|
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()
|
templates, err = ListLearnedTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -325,8 +364,9 @@ func TestListLearnedTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildTemplate(t *testing.T) {
|
func TestBuildTemplate(t *testing.T) {
|
||||||
|
allowRead := []string{"~/external-data"}
|
||||||
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
||||||
result := buildTemplate("opencode", allowWrite)
|
result := buildTemplate("opencode", allowRead, allowWrite)
|
||||||
|
|
||||||
// Check header comments
|
// Check header comments
|
||||||
if !strings.Contains(result, `Learned template for "opencode"`) {
|
if !strings.Contains(result, `Learned template for "opencode"`) {
|
||||||
@@ -340,6 +380,12 @@ func TestBuildTemplate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check content
|
// 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"`) {
|
if !strings.Contains(result, `"allowWrite"`) {
|
||||||
t.Error("template missing allowWrite field")
|
t.Error("template missing allowWrite field")
|
||||||
}
|
}
|
||||||
@@ -352,14 +398,28 @@ func TestBuildTemplate(t *testing.T) {
|
|||||||
if !strings.Contains(result, `"denyRead"`) {
|
if !strings.Contains(result, `"denyRead"`) {
|
||||||
t.Error("template missing denyRead field")
|
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) {
|
func TestGenerateLearnedTemplate(t *testing.T) {
|
||||||
// Create a temp dir for templates
|
// Create a temp dir for templates
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||||
os.Setenv("XDG_CONFIG_HOME", tmpDir)
|
|
||||||
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
|
|
||||||
|
|
||||||
// Create a fake strace log
|
// Create a fake strace log
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
@@ -372,7 +432,7 @@ func TestGenerateLearnedTemplate(t *testing.T) {
|
|||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
logFile := filepath.Join(tmpDir, "strace.log")
|
logFile := filepath.Join(tmpDir, "strace.log")
|
||||||
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
|
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +446,7 @@ func TestGenerateLearnedTemplate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read and verify template
|
// Read and verify template
|
||||||
data, err := os.ReadFile(templatePath)
|
data, err := os.ReadFile(templatePath) //nolint:gosec // reading test-generated template file
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read template: %v", err)
|
t.Fatalf("failed to read template: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,12 @@ func getMandatoryDenyPaths(cwd string) []string {
|
|||||||
paths = append(paths, p)
|
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
|
// Git hooks in cwd
|
||||||
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
|
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
|
||||||
|
|
||||||
@@ -389,6 +395,203 @@ func getMandatoryDenyPaths(cwd string) []string {
|
|||||||
return paths
|
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.
|
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
// 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) {
|
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||||
@@ -478,54 +681,29 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
|
|||||||
bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd)
|
bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make XDG_RUNTIME_DIR writable so dconf and other runtime services
|
||||||
|
// (Wayland, PulseAudio, D-Bus) work inside the sandbox.
|
||||||
|
// Writes to /run/ are already filtered out by the learning parser.
|
||||||
|
xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if xdgRuntime != "" && fileExists(xdgRuntime) {
|
||||||
|
bwrapArgs = append(bwrapArgs, "--bind", xdgRuntime, xdgRuntime)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
|
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
|
||||||
|
|
||||||
if opts.Learning {
|
switch {
|
||||||
|
case opts.Learning:
|
||||||
// Skip defaultDenyRead logic in learning mode (already set up above)
|
// Skip defaultDenyRead logic in learning mode (already set up above)
|
||||||
} else if defaultDenyRead {
|
case defaultDenyRead:
|
||||||
// In defaultDenyRead mode, we only bind essential system paths read-only
|
// Deny-by-default mode: start with empty root, then whitelist system paths + CWD
|
||||||
// and user-specified allowRead paths. Everything else is inaccessible.
|
|
||||||
if opts.Debug {
|
if opts.Debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
|
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n")
|
||||||
}
|
}
|
||||||
|
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
|
||||||
// Bind essential system paths read-only
|
default:
|
||||||
// Skip /dev, /proc, /tmp as they're mounted with special options below
|
// Legacy mode: bind entire root filesystem read-only
|
||||||
for _, systemPath := range GetDefaultReadablePaths() {
|
|
||||||
if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" ||
|
|
||||||
systemPath == "/private/tmp" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fileExists(systemPath) {
|
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind user-specified allowRead paths
|
|
||||||
if cfg != nil && cfg.Filesystem.AllowRead != nil {
|
|
||||||
boundPaths := make(map[string]bool)
|
|
||||||
|
|
||||||
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
|
|
||||||
for _, p := range expandedPaths {
|
|
||||||
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
|
|
||||||
boundPaths[p] = true
|
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add non-glob paths
|
|
||||||
for _, p := range cfg.Filesystem.AllowRead {
|
|
||||||
normalized := NormalizePath(p)
|
|
||||||
if !ContainsGlobChars(normalized) && fileExists(normalized) &&
|
|
||||||
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
|
|
||||||
boundPaths[normalized] = true
|
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default mode: bind entire root filesystem read-only
|
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
|
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,10 +857,20 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
|
|||||||
// subdirectory dangerous files without full tree walks that hang on large dirs.
|
// subdirectory dangerous files without full tree walks that hang on large dirs.
|
||||||
mandatoryDeny := getMandatoryDenyPaths(cwd)
|
mandatoryDeny := getMandatoryDenyPaths(cwd)
|
||||||
|
|
||||||
|
// In deny-by-default mode, sensitive project files are already masked
|
||||||
|
// with --ro-bind /dev/null by buildDenyByDefaultMounts(). Skip them here
|
||||||
|
// to avoid overriding the /dev/null mask with a real ro-bind.
|
||||||
|
maskedPaths := make(map[string]bool)
|
||||||
|
if defaultDenyRead {
|
||||||
|
for _, f := range SensitiveProjectFiles {
|
||||||
|
maskedPaths[filepath.Join(cwd, f)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, p := range mandatoryDeny {
|
for _, p := range mandatoryDeny {
|
||||||
if !seen[p] && fileExists(p) {
|
if !seen[p] && fileExists(p) && !maskedPaths[p] {
|
||||||
seen[p] = true
|
seen[p] = true
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
|
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
|
||||||
}
|
}
|
||||||
@@ -750,7 +938,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
|
|||||||
// Supported by glibc, Go 1.21+, c-ares, and most DNS resolver libraries.
|
// 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.WriteString("nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions use-vc\n")
|
||||||
}
|
}
|
||||||
tmpResolv.Close()
|
_ = tmpResolv.Close()
|
||||||
dnsRelayResolvConf = tmpResolv.Name()
|
dnsRelayResolvConf = tmpResolv.Name()
|
||||||
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
|
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
|
||||||
if opts.Debug {
|
if opts.Debug {
|
||||||
@@ -788,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")
|
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")
|
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
|
||||||
|
|
||||||
// Build the inner command that sets up tun2socks and runs the user command
|
// Build the inner command that sets up tun2socks and runs the user command
|
||||||
@@ -898,7 +1094,8 @@ sleep 0.3
|
|||||||
// after the main command exits; the user can Ctrl+C to stop it.
|
// after the main command exits; the user can Ctrl+C to stop it.
|
||||||
// A SIGCHLD trap kills strace once its direct child exits, handling
|
// A SIGCHLD trap kills strace once its direct child exits, handling
|
||||||
// the common case of background daemons (LSP servers, watchers).
|
// the common case of background daemons (LSP servers, watchers).
|
||||||
if opts.Learning && opts.StraceLogPath != "" {
|
switch {
|
||||||
|
case opts.Learning && opts.StraceLogPath != "":
|
||||||
innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access (foreground for terminal access)
|
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
|
strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s
|
||||||
GREYWALL_STRACE_EXIT=$?
|
GREYWALL_STRACE_EXIT=$?
|
||||||
@@ -910,7 +1107,7 @@ exit $GREYWALL_STRACE_EXIT
|
|||||||
`,
|
`,
|
||||||
ShellQuoteSingle(opts.StraceLogPath), command,
|
ShellQuoteSingle(opts.StraceLogPath), command,
|
||||||
))
|
))
|
||||||
} else if useLandlockWrapper {
|
case useLandlockWrapper:
|
||||||
// Use Landlock wrapper if available
|
// Use Landlock wrapper if available
|
||||||
// Pass config via environment variable (serialized as JSON)
|
// Pass config via environment variable (serialized as JSON)
|
||||||
// This ensures allowWrite/denyWrite rules are properly applied
|
// This ensures allowWrite/denyWrite rules are properly applied
|
||||||
@@ -931,7 +1128,7 @@ exit $GREYWALL_STRACE_EXIT
|
|||||||
|
|
||||||
// Use exec to replace bash with the wrapper (which will exec the command)
|
// Use exec to replace bash with the wrapper (which will exec the command)
|
||||||
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
|
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
|
||||||
} else {
|
default:
|
||||||
innerScript.WriteString(command)
|
innerScript.WriteString(command)
|
||||||
innerScript.WriteString("\n")
|
innerScript.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ func suggestInstallCmd(features *LinuxFeatures) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readSysctl(name string) string {
|
func readSysctl(name string) string {
|
||||||
data, err := os.ReadFile("/proc/sys/" + name)
|
data, err := os.ReadFile("/proc/sys/" + name) //nolint:gosec // reading sysctl values - trusted kernel path
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 cwd != "" {
|
||||||
if err := ruleset.AllowRead(cwd); err != nil && debug {
|
if err := ruleset.AllowReadWrite(cwd); err != nil && debug {
|
||||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read path: %v\n", err)
|
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add cwd read/write path: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home directory - read access
|
// Home directory - read access only when not in deny-by-default mode.
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
// In deny-by-default mode, only specific user tooling paths are allowed,
|
||||||
if err := ruleset.AllowRead(home); err != nil && debug {
|
// not the entire home directory. Landlock can't selectively deny files
|
||||||
fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add home read path: %v\n", err)
|
// 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) {
|
func NewLandlockRuleset(debug bool) (*LandlockRuleset, error) {
|
||||||
features := DetectLinuxFeatures()
|
features := DetectLinuxFeatures()
|
||||||
if !features.CanUseLandlock() {
|
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)
|
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.
|
// Apply applies the Landlock ruleset to the current process.
|
||||||
func (l *LandlockRuleset) Apply() error {
|
func (l *LandlockRuleset) Apply() error {
|
||||||
if !l.initialized {
|
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)
|
// Set NO_NEW_PRIVS first (required for Landlock)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type MacOSSandboxParams struct {
|
|||||||
AllowLocalBinding bool
|
AllowLocalBinding bool
|
||||||
AllowLocalOutbound bool
|
AllowLocalOutbound bool
|
||||||
DefaultDenyRead bool
|
DefaultDenyRead bool
|
||||||
|
Cwd string // Current working directory (for deny-by-default CWD allowlisting)
|
||||||
ReadAllowPaths []string
|
ReadAllowPaths []string
|
||||||
ReadDenyPaths []string
|
ReadDenyPaths []string
|
||||||
WriteAllowPaths []string
|
WriteAllowPaths []string
|
||||||
@@ -146,13 +147,13 @@ func getTmpdirParent() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateReadRules generates filesystem read rules for the sandbox profile.
|
// 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
|
var rules []string
|
||||||
|
|
||||||
if defaultDenyRead {
|
if defaultDenyRead {
|
||||||
// When defaultDenyRead is enabled:
|
// When defaultDenyRead is enabled:
|
||||||
// 1. Allow file-read-metadata globally (needed for directory traversal, stat, etc.)
|
// 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.
|
// 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)
|
// 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
|
// Allow reading data from user-specified paths
|
||||||
for _, pathPattern := range allowPaths {
|
for _, pathPattern := range allowPaths {
|
||||||
normalized := NormalizePath(pathPattern)
|
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 {
|
} else {
|
||||||
// Allow all reads by default
|
// Allow all reads by default
|
||||||
rules = append(rules, "(allow file-read*)")
|
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.
|
// 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
|
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
|
// Allow TMPDIR parent on macOS
|
||||||
for _, tmpdirParent := range getTmpdirParent() {
|
for _, tmpdirParent := range getTmpdirParent() {
|
||||||
normalized := NormalizePath(tmpdirParent)
|
normalized := NormalizePath(tmpdirParent)
|
||||||
@@ -254,8 +321,11 @@ func generateWriteRules(allowPaths, denyPaths []string, allowGitConfig bool, log
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Combine user-specified and mandatory deny patterns
|
// Combine user-specified and mandatory deny patterns
|
||||||
cwd, _ := os.Getwd()
|
mandatoryCwd := cwd
|
||||||
mandatoryDeny := GetMandatoryDenyPatterns(cwd, allowGitConfig)
|
if mandatoryCwd == "" {
|
||||||
|
mandatoryCwd, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
mandatoryDeny := GetMandatoryDenyPatterns(mandatoryCwd, allowGitConfig)
|
||||||
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
|
allDenyPaths := make([]string, 0, len(denyPaths)+len(mandatoryDeny))
|
||||||
allDenyPaths = append(allDenyPaths, denyPaths...)
|
allDenyPaths = append(allDenyPaths, denyPaths...)
|
||||||
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
|
allDenyPaths = append(allDenyPaths, mandatoryDeny...)
|
||||||
@@ -530,14 +600,14 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
|||||||
|
|
||||||
// Read rules
|
// Read rules
|
||||||
profile.WriteString("; File read\n")
|
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(rule + "\n")
|
||||||
}
|
}
|
||||||
profile.WriteString("\n")
|
profile.WriteString("\n")
|
||||||
|
|
||||||
// Write rules
|
// Write rules
|
||||||
profile.WriteString("; File write\n")
|
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")
|
profile.WriteString(rule + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +632,8 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
|||||||
|
|
||||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
|
||||||
// Build allow paths: default + configured
|
// Build allow paths: default + configured
|
||||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||||
|
|
||||||
@@ -599,7 +671,8 @@ func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, de
|
|||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
AllowLocalBinding: allowLocalBinding,
|
AllowLocalBinding: allowLocalBinding,
|
||||||
AllowLocalOutbound: allowLocalOutbound,
|
AllowLocalOutbound: allowLocalOutbound,
|
||||||
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
|
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
|
||||||
|
Cwd: cwd,
|
||||||
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
||||||
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||||
WriteAllowPaths: allowPaths,
|
WriteAllowPaths: allowPaths,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -108,7 +109,8 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
|||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
AllowLocalBinding: allowLocalBinding,
|
AllowLocalBinding: allowLocalBinding,
|
||||||
AllowLocalOutbound: allowLocalOutbound,
|
AllowLocalOutbound: allowLocalOutbound,
|
||||||
DefaultDenyRead: cfg.Filesystem.DefaultDenyRead,
|
DefaultDenyRead: cfg.Filesystem.IsDefaultDenyRead(),
|
||||||
|
Cwd: "/tmp/test-project",
|
||||||
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
ReadAllowPaths: cfg.Filesystem.AllowRead,
|
||||||
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
ReadDenyPaths: cfg.Filesystem.DenyRead,
|
||||||
WriteAllowPaths: allowPaths,
|
WriteAllowPaths: allowPaths,
|
||||||
@@ -175,38 +177,46 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
defaultDenyRead bool
|
defaultDenyRead bool
|
||||||
|
cwd string
|
||||||
allowRead []string
|
allowRead []string
|
||||||
wantContainsBlanketAllow bool
|
wantContainsBlanketAllow bool
|
||||||
wantContainsMetadataAllow bool
|
wantContainsMetadataAllow bool
|
||||||
wantContainsSystemAllows bool
|
wantContainsSystemAllows bool
|
||||||
wantContainsUserAllowRead bool
|
wantContainsUserAllowRead bool
|
||||||
|
wantContainsCwdAllow bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default mode - blanket allow read",
|
name: "legacy mode - blanket allow read",
|
||||||
defaultDenyRead: false,
|
defaultDenyRead: false,
|
||||||
|
cwd: "/home/user/project",
|
||||||
allowRead: nil,
|
allowRead: nil,
|
||||||
wantContainsBlanketAllow: true,
|
wantContainsBlanketAllow: true,
|
||||||
wantContainsMetadataAllow: false,
|
wantContainsMetadataAllow: false,
|
||||||
wantContainsSystemAllows: false,
|
wantContainsSystemAllows: false,
|
||||||
wantContainsUserAllowRead: 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,
|
defaultDenyRead: true,
|
||||||
|
cwd: "/home/user/project",
|
||||||
allowRead: nil,
|
allowRead: nil,
|
||||||
wantContainsBlanketAllow: false,
|
wantContainsBlanketAllow: false,
|
||||||
wantContainsMetadataAllow: true,
|
wantContainsMetadataAllow: true,
|
||||||
wantContainsSystemAllows: true,
|
wantContainsSystemAllows: true,
|
||||||
wantContainsUserAllowRead: false,
|
wantContainsUserAllowRead: false,
|
||||||
|
wantContainsCwdAllow: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "defaultDenyRead with allowRead paths",
|
name: "defaultDenyRead with allowRead paths",
|
||||||
defaultDenyRead: true,
|
defaultDenyRead: true,
|
||||||
allowRead: []string{"/home/user/project"},
|
cwd: "/home/user/project",
|
||||||
|
allowRead: []string{"/home/user/other"},
|
||||||
wantContainsBlanketAllow: false,
|
wantContainsBlanketAllow: false,
|
||||||
wantContainsMetadataAllow: true,
|
wantContainsMetadataAllow: true,
|
||||||
wantContainsSystemAllows: true,
|
wantContainsSystemAllows: true,
|
||||||
wantContainsUserAllowRead: true,
|
wantContainsUserAllowRead: true,
|
||||||
|
wantContainsCwdAllow: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +225,7 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
params := MacOSSandboxParams{
|
params := MacOSSandboxParams{
|
||||||
Command: "echo test",
|
Command: "echo test",
|
||||||
DefaultDenyRead: tt.defaultDenyRead,
|
DefaultDenyRead: tt.defaultDenyRead,
|
||||||
|
Cwd: tt.cwd,
|
||||||
ReadAllowPaths: tt.allowRead,
|
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)
|
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 {
|
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
|
||||||
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
||||||
if !hasUserAllow {
|
if !hasUserAllow {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (m *Manager) Initialize() error {
|
|||||||
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
|
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if m.tun2socksPath != "" {
|
if m.tun2socksPath != "" {
|
||||||
os.Remove(m.tun2socksPath)
|
_ = os.Remove(m.tun2socksPath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
|
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func (m *Manager) Initialize() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
m.proxyBridge.Cleanup()
|
m.proxyBridge.Cleanup()
|
||||||
if m.tun2socksPath != "" {
|
if m.tun2socksPath != "" {
|
||||||
os.Remove(m.tun2socksPath)
|
_ = os.Remove(m.tun2socksPath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to initialize DNS bridge: %w", err)
|
return fmt.Errorf("failed to initialize DNS bridge: %w", err)
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ func (m *Manager) Initialize() error {
|
|||||||
m.proxyBridge.Cleanup()
|
m.proxyBridge.Cleanup()
|
||||||
}
|
}
|
||||||
if m.tun2socksPath != "" {
|
if m.tun2socksPath != "" {
|
||||||
os.Remove(m.tun2socksPath)
|
_ = os.Remove(m.tun2socksPath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ func (m *Manager) wrapCommandLearning(command string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create strace log file: %w", err)
|
return "", fmt.Errorf("failed to create strace log file: %w", err)
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
m.straceLogPath = tmpFile.Name()
|
m.straceLogPath = tmpFile.Name()
|
||||||
|
|
||||||
m.logDebug("Strace log file: %s", m.straceLogPath)
|
m.logDebug("Strace log file: %s", m.straceLogPath)
|
||||||
@@ -193,7 +193,7 @@ func (m *Manager) GenerateLearnedTemplate(cmdName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up strace log since we've processed it
|
// Clean up strace log since we've processed it
|
||||||
os.Remove(m.straceLogPath)
|
_ = os.Remove(m.straceLogPath)
|
||||||
m.straceLogPath = ""
|
m.straceLogPath = ""
|
||||||
|
|
||||||
return templatePath, nil
|
return templatePath, nil
|
||||||
@@ -211,10 +211,10 @@ func (m *Manager) Cleanup() {
|
|||||||
m.proxyBridge.Cleanup()
|
m.proxyBridge.Cleanup()
|
||||||
}
|
}
|
||||||
if m.tun2socksPath != "" {
|
if m.tun2socksPath != "" {
|
||||||
os.Remove(m.tun2socksPath)
|
_ = os.Remove(m.tun2socksPath)
|
||||||
}
|
}
|
||||||
if m.straceLogPath != "" {
|
if m.straceLogPath != "" {
|
||||||
os.Remove(m.straceLogPath)
|
_ = os.Remove(m.straceLogPath)
|
||||||
m.straceLogPath = ""
|
m.straceLogPath = ""
|
||||||
}
|
}
|
||||||
m.logDebug("Sandbox manager cleaned up")
|
m.logDebug("Sandbox manager cleaned up")
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ func extractTun2Socks() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tmpFile.Write(data); err != nil {
|
if _, err := tmpFile.Write(data); err != nil {
|
||||||
tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
os.Remove(tmpFile.Name())
|
_ = os.Remove(tmpFile.Name())
|
||||||
return "", fmt.Errorf("tun2socks: failed to write binary: %w", err)
|
return "", fmt.Errorf("tun2socks: failed to write binary: %w", err)
|
||||||
}
|
}
|
||||||
tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
|
|
||||||
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil {
|
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil { //nolint:gosec // executable binary needs execute permission
|
||||||
os.Remove(tmpFile.Name())
|
_ = os.Remove(tmpFile.Name())
|
||||||
return "", fmt.Errorf("tun2socks: failed to make executable: %w", err)
|
return "", fmt.Errorf("tun2socks: failed to make executable: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,5 +149,5 @@ git push origin "$NEW_VERSION"
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
info "✓ Released $NEW_VERSION"
|
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"
|
info "Watch progress at: https://gitea.app.monadical.io/monadical/greywall/actions"
|
||||||
|
|||||||
Reference in New Issue
Block a user