13 Commits

Author SHA1 Message Date
juanarias8
b1ad4b9803 chore(ci): update golangci-lint and fix script paths
All checks were successful
Build and test / Lint (pull_request) Successful in 1m42s
Build and test / Test (Linux) (pull_request) Successful in 1m1s
Build and test / Build (pull_request) Successful in 12s
- bump golangci-lint-action to v7 and version to v2.1.6
- correct directory paths in `smoke_test.sh`
- update `test` and `test-ci` targets in Makefile
2026-03-11 00:34:40 -05:00
juanarias8
9a3d863696 test(install): add script to test install logic
Some checks failed
Build and test / Build (pull_request) Successful in 15s
Build and test / Lint (pull_request) Failing after 1m10s
Build and test / Test (Linux) (pull_request) Failing after 58s
- verify version detection and error handling
- check URL construction and download tests
- include optional live install integration tests
2026-03-10 14:27:29 -05:00
juanarias8
8565916178 feat(install): improve version tag validation and update download URL
- ensure valid semver tags when fetching version
- validate fallback version tag format
- switch download URL to Gitea releases
2026-03-10 14:25:52 -05:00
f4a5c98328 feat: add greywall check and greywall setup commands
Some checks failed
Build and test / Lint (push) Failing after 1m12s
Build and test / Build (push) Successful in 20s
Build and test / Test (Linux) (push) Failing after 1m4s
Add diagnostic and setup commands so users can verify their environment
and install greyproxy without leaving greywall:

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

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

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

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

Now checks isDirectory() and uses filepath.Dir() for file paths so
intermediary dirs are only created up to the parent.
2026-02-13 13:53:19 -06:00
f4c9422f77 feat: migrate CI and releases from GitHub Actions to Gitea Actions
Retarget GoReleaser to publish to Gitea (gitea_urls, release.gitea,
changelog.use: gitea). Add Gitea Actions workflows for build/test,
release, and benchmarks — adapted from GitHub equivalents with macOS
jobs and SLSA provenance dropped. Old .github/workflows/ kept in place.
2026-02-13 12:20:32 -06:00
c19370f8b3 feat: deny-by-default filesystem isolation
Some checks failed
Build and test / Lint (push) Failing after 1m16s
Build and test / Build (push) Successful in 13s
Build and test / Test (Linux) (push) Failing after 41s
Build and test / Test (macOS) (push) Has been cancelled
- Deny-by-default filesystem isolation for Linux (Landlock) and macOS (Seatbelt)
- Prevent learning mode from collapsing read paths to $HOME
- Add Linux deny-by-default lessons to experience docs
2026-02-13 11:39:18 -06:00
25 changed files with 1209 additions and 161 deletions

View File

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

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

@@ -0,0 +1,118 @@
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: Download tun2socks binaries
run: make download-tun2socks
- name: Install golangci-lint
run: GOTOOLCHAIN=local go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6
- name: Lint
run: golangci-lint run --allow-parallel-runners
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: ./scripts/smoke_test.sh ./greywall

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ scripts/ Smoke tests, benchmarks, release
- **Language:** Go 1.25+
- **Formatter:** `gofumpt` (enforced in CI)
- **Linter:** `golangci-lint` v1.64.8 (config in `.golangci.yml`)
- **Linter:** `golangci-lint` v2 (config in `.golangci.yml`)
- **Import order:** stdlib, third-party, local (`gitea.app.monadical.io/monadical/greywall`)
- **Platform code:** build tags (`//go:build linux`, `//go:build darwin`) with `*_stub.go` for unsupported platforms
- **Error handling:** custom error types (e.g., `CommandBlockedError`)

View File

@@ -38,11 +38,11 @@ build-ci: download-tun2socks
$(eval GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown"))
$(GOBUILD) -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)" -o $(BINARY_NAME) -v ./cmd/greywall
test:
test: download-tun2socks
@echo "Running tests..."
$(GOTEST) -v ./...
test-ci:
test-ci: download-tun2socks
@echo "CI: Running tests with coverage..."
$(GOTEST) -v -race -coverprofile=coverage.out ./...
@@ -70,7 +70,7 @@ build-darwin:
install-lint-tools:
@echo "Installing linting tools..."
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
@echo "Linting tools installed"
setup: deps install-lint-tools

124
README.md
View File

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

View File

@@ -15,6 +15,7 @@ import (
"gitea.app.monadical.io/monadical/greywall/internal/config"
"gitea.app.monadical.io/monadical/greywall/internal/platform"
"gitea.app.monadical.io/monadical/greywall/internal/proxy"
"gitea.app.monadical.io/monadical/greywall/internal/sandbox"
"github.com/spf13/cobra"
)
@@ -55,8 +56,8 @@ func main() {
Long: `greywall is a command-line tool that runs commands in a sandboxed environment
with network and filesystem restrictions.
By default, traffic is routed through the GreyHaven SOCKS5 proxy at localhost:42051
with DNS via localhost:42053. Use --proxy and --dns to override, or configure in
By default, traffic is routed through the GreyProxy SOCKS5 proxy at localhost:43052
with DNS via localhost:43053. Use --proxy and --dns to override, or configure in
your settings file at ~/.config/greywall/greywall.json (or ~/Library/Application Support/greywall/greywall.json on macOS).
On Linux, greywall uses tun2socks for truly transparent proxying: all TCP/UDP traffic
@@ -98,8 +99,8 @@ Configuration file format:
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations")
rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)")
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:42052)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:42053)")
rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (default: socks5://localhost:43052)")
rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (default: localhost:43053)")
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)")
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
@@ -111,6 +112,8 @@ Configuration file format:
rootCmd.AddCommand(newCompletionCmd(rootCmd))
rootCmd.AddCommand(newTemplatesCmd())
rootCmd.AddCommand(newCheckCmd())
rootCmd.AddCommand(newSetupCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -121,11 +124,7 @@ Configuration file format:
func runCommand(cmd *cobra.Command, args []string) error {
if showVersion {
fmt.Printf("greywall - lightweight, container-free sandbox for running untrusted commands\n")
fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Built: %s\n", buildTime)
fmt.Printf(" Commit: %s\n", gitCommit)
sandbox.PrintDependencyStatus()
fmt.Printf("greywall %s\n", version)
return nil
}
@@ -203,19 +202,20 @@ func runCommand(cmd *cobra.Command, args []string) error {
if templatePath != "" {
learnedCfg, loadErr := config.Load(templatePath)
if loadErr != nil {
switch {
case loadErr != nil:
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Warning: failed to load learned template: %v\n", loadErr)
}
} else if learnedCfg != nil {
case learnedCfg != nil:
cfg = config.Merge(cfg, learnedCfg)
if debug {
fmt.Fprintf(os.Stderr, "[greywall] Auto-loaded learned template for %q\n", templateLabel)
}
} else if templateName != "" {
case templateName != "":
// Explicit --template but file doesn't exist
return fmt.Errorf("learned template %q not found at %s\nRun: greywall templates list", templateName, templatePath)
} else if cmdName != "" {
case cmdName != "":
// No template found for this command - suggest creating one
fmt.Fprintf(os.Stderr, "[greywall] No learned template for %q. Run with --learning to create one.\n", cmdName)
}
@@ -230,18 +230,18 @@ func runCommand(cmd *cobra.Command, args []string) error {
cfg.Network.DnsAddr = dnsAddr
}
// GreyHaven defaults: when no proxy or DNS is configured (neither via CLI
// nor config file), use the standard GreyHaven infrastructure ports.
// GreyProxy defaults: when no proxy or DNS is configured (neither via CLI
// nor config file), use the standard GreyProxy ports.
if cfg.Network.ProxyURL == "" {
cfg.Network.ProxyURL = "socks5://localhost:42052"
cfg.Network.ProxyURL = "socks5://localhost:43052"
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 == "" {
cfg.Network.DnsAddr = "localhost:42053"
cfg.Network.DnsAddr = "localhost:43053"
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)
}
// newCheckCmd creates the check subcommand for diagnostics.
func newCheckCmd() *cobra.Command {
return &cobra.Command{
Use: "check",
Short: "Check greywall status, dependencies, and greyproxy connectivity",
Long: `Run diagnostics to check greywall readiness.
Shows version information, platform dependencies, security features,
and greyproxy installation/running status.`,
Args: cobra.NoArgs,
RunE: runCheck,
}
}
func runCheck(_ *cobra.Command, _ []string) error {
fmt.Printf("greywall - lightweight, container-free sandbox for running untrusted commands\n")
fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Built: %s\n", buildTime)
fmt.Printf(" Commit: %s\n", gitCommit)
sandbox.PrintDependencyStatus()
fmt.Printf("\n Greyproxy:\n")
status := proxy.Detect()
if status.Installed {
if status.Version != "" {
fmt.Printf(" ✓ installed (v%s) at %s\n", status.Version, status.Path)
} else {
fmt.Printf(" ✓ installed at %s\n", status.Path)
}
if status.Running {
fmt.Printf(" ✓ running (SOCKS5 :43052, DNS :43053, Dashboard :43080)\n")
} else {
fmt.Printf(" ✗ not running\n")
fmt.Printf(" Start with: greywall setup\n")
}
} else {
fmt.Printf(" ✗ not installed\n")
fmt.Printf(" Install with: greywall setup\n")
}
return nil
}
// newSetupCmd creates the setup subcommand for installing greyproxy.
func newSetupCmd() *cobra.Command {
return &cobra.Command{
Use: "setup",
Short: "Install and start greyproxy (network proxy for sandboxed commands)",
Long: `Downloads and installs greyproxy from GitHub releases.
greyproxy provides SOCKS5 proxying and DNS resolution for sandboxed commands.
The installer will:
1. Download the latest greyproxy release for your platform
2. Install the binary to ~/.local/bin/greyproxy
3. Register and start a systemd user service`,
Args: cobra.NoArgs,
RunE: runSetup,
}
}
func runSetup(_ *cobra.Command, _ []string) error {
status := proxy.Detect()
if status.Installed && status.Running {
fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version)
fmt.Printf("Run 'greywall check' for full status.\n")
return nil
}
if status.Installed && !status.Running {
if err := proxy.Start(os.Stderr); err != nil {
return err
}
fmt.Printf("greyproxy started.\n")
return nil
}
return proxy.Install(proxy.InstallOptions{
Output: os.Stderr,
})
}
// newCompletionCmd creates the completion subcommand for shell completions.
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
cmd := &cobra.Command{
@@ -503,7 +586,7 @@ Examples:
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
templatePath := sandbox.LearnedTemplatePath(name)
data, err := os.ReadFile(templatePath)
data, err := os.ReadFile(templatePath) //nolint:gosec // user-specified template path - intentional
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("template %q not found\nRun: greywall templates list", name)

View File

@@ -47,12 +47,17 @@ if [ -n "$REQUESTED_VERSION" ]; then
*) VERSION_TAG="v$REQUESTED_VERSION" ;;
esac
else
# Try manifest first (fast, no rate limits)
VERSION_TAG=$(curl -sL "https://gitea.app.monadical.io/monadical/greywall/latest.txt" 2>/dev/null || echo "")
# Fallback to GitHub API if manifest fails
# Try manifest first (fast, no rate limits) — only accept valid semver tags
VERSION_TAG=$(curl -sL "https://gitea.app.monadical.io/monadical/greywall/raw/branch/gh-pages/latest.txt" 2>/dev/null | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "")
# Fallback to Gitea API if manifest fails
if [ -z "$VERSION_TAG" ]; then
VERSION_TAG=$(curl -s "https://gitea.app.monadical.io/api/v1/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
# Validate it looks like a version tag
case "$VERSION_TAG" in
v[0-9]*) ;;
*) VERSION_TAG="" ;;
esac
fi
fi
@@ -69,7 +74,7 @@ case "$OS" in
*) OS_TITLE="$OS" ;;
esac
DOWNLOAD_URL="https://github.com/$REPO/releases/download/${VERSION_TAG}/${BINARY_NAME}_${VERSION_NUMBER}_${OS_TITLE}_${ARCH}.tar.gz"
DOWNLOAD_URL="https://gitea.app.monadical.io/$REPO/releases/download/${VERSION_TAG}/${BINARY_NAME}_${VERSION_NUMBER}_${OS_TITLE}_${ARCH}.tar.gz"
TMP_DIR=$(mktemp -d)
cd "$TMP_DIR"

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

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

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

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

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

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

View File

@@ -166,11 +166,11 @@ func GenerateLearnedTemplate(straceLogPath, cmdName string, debug bool) (string,
// Save template
templatePath := LearnedTemplatePath(cmdName)
if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(templatePath), 0o750); err != nil {
return "", fmt.Errorf("failed to create template directory: %w", err)
}
if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil {
if err := os.WriteFile(templatePath, []byte(template), 0o600); err != nil {
return "", fmt.Errorf("failed to write template: %w", err)
}
@@ -305,11 +305,7 @@ func isSensitivePath(path, home string) bool {
// Check GPG
gnupgDir := filepath.Join(home, ".gnupg")
if strings.HasPrefix(path, gnupgDir+"/") {
return true
}
return false
return strings.HasPrefix(path, gnupgDir+"/")
}
// getDangerousFilePatterns returns denyWrite entries for DangerousFiles.

View File

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

View File

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

View File

@@ -70,9 +70,7 @@ func TestFindApplicationDirectory(t *testing.T) {
func TestCollapsePaths(t *testing.T) {
// Temporarily override home for testing
origHome := os.Getenv("HOME")
os.Setenv("HOME", "/home/testuser")
defer os.Setenv("HOME", origHome)
t.Setenv("HOME", "/home/testuser")
tests := []struct {
name string
@@ -319,9 +317,7 @@ func TestToTildePath(t *testing.T) {
func TestListLearnedTemplates(t *testing.T) {
// Use a temp dir to isolate from real user config
tmpDir := t.TempDir()
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Initially empty
templates, err := ListLearnedTemplates()
@@ -334,10 +330,18 @@ func TestListLearnedTemplates(t *testing.T) {
// Create some templates
dir := LearnedTemplateDir()
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o644)
os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o644)
os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o644) // should be ignored
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o600); err != nil {
t.Fatal(err)
}
templates, err = ListLearnedTemplates()
if err != nil {
@@ -415,9 +419,7 @@ func TestBuildTemplateNoAllowRead(t *testing.T) {
func TestGenerateLearnedTemplate(t *testing.T) {
// Create a temp dir for templates
tmpDir := t.TempDir()
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer os.Setenv("XDG_CONFIG_HOME", origConfigDir)
t.Setenv("XDG_CONFIG_HOME", tmpDir)
// Create a fake strace log
home, _ := os.UserHomeDir()
@@ -430,7 +432,7 @@ func TestGenerateLearnedTemplate(t *testing.T) {
}, "\n")
logFile := filepath.Join(tmpDir, "strace.log")
if err := os.WriteFile(logFile, []byte(logContent), 0o644); err != nil {
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
t.Fatal(err)
}
@@ -444,7 +446,7 @@ func TestGenerateLearnedTemplate(t *testing.T) {
}
// Read and verify template
data, err := os.ReadFile(templatePath)
data, err := os.ReadFile(templatePath) //nolint:gosec // reading test-generated template file
if err != nil {
t.Fatalf("failed to read template: %v", err)
}

View File

@@ -519,8 +519,14 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []stri
if fileExists(p) && canMountOver(p) &&
!strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
boundPaths[p] = true
// Create intermediary dirs if needed
for _, dir := range intermediaryDirs("/", p) {
// 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)
}
@@ -533,7 +539,11 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []stri
if !ContainsGlobChars(normalized) && fileExists(normalized) && canMountOver(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
for _, dir := range intermediaryDirs("/", normalized) {
dirTarget := normalized
if !isDirectory(normalized) {
dirTarget = filepath.Dir(normalized)
}
for _, dir := range intermediaryDirs("/", dirTarget) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}
@@ -554,7 +564,7 @@ func buildDenyByDefaultMounts(cfg *config.Config, cwd string, debug bool) []stri
if emptyFile == "" {
emptyFile = filepath.Join(os.TempDir(), "greywall", "empty")
_ = os.MkdirAll(filepath.Dir(emptyFile), 0o750)
_ = os.WriteFile(emptyFile, nil, 0o444)
_ = 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 {
@@ -671,19 +681,28 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
bwrapArgs = append(bwrapArgs, "--bind", cwd, cwd)
}
// Make XDG_RUNTIME_DIR writable so dconf and other runtime services
// (Wayland, PulseAudio, D-Bus) work inside the sandbox.
// Writes to /run/ are already filtered out by the learning parser.
xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
if xdgRuntime != "" && fileExists(xdgRuntime) {
bwrapArgs = append(bwrapArgs, "--bind", xdgRuntime, xdgRuntime)
}
}
defaultDenyRead := cfg != nil && cfg.Filesystem.IsDefaultDenyRead()
if opts.Learning {
switch {
case opts.Learning:
// Skip defaultDenyRead logic in learning mode (already set up above)
} else if defaultDenyRead {
case defaultDenyRead:
// Deny-by-default mode: start with empty root, then whitelist system paths + CWD
if opts.Debug {
fmt.Fprintf(os.Stderr, "[greywall:linux] DefaultDenyRead mode enabled - tmpfs root with selective mounts\n")
}
bwrapArgs = append(bwrapArgs, buildDenyByDefaultMounts(cfg, cwd, opts.Debug)...)
} else {
default:
// Legacy mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
}
@@ -919,7 +938,7 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
// Supported by glibc, Go 1.21+, c-ares, and most DNS resolver libraries.
_, _ = tmpResolv.WriteString("nameserver 1.1.1.1\nnameserver 8.8.8.8\noptions use-vc\n")
}
tmpResolv.Close()
_ = tmpResolv.Close()
dnsRelayResolvConf = tmpResolv.Name()
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
if opts.Debug {
@@ -957,6 +976,14 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge
fmt.Fprintf(os.Stderr, "[greywall:linux] Skipping Landlock wrapper (running as library, not greywall CLI)\n")
}
// Bind-mount the greywall binary into the sandbox so the Landlock wrapper
// can re-execute it. Without this, running greywall from a directory that
// isn't the CWD (e.g., ~/bin/greywall from /home/user/project) would fail
// because the binary path doesn't exist inside the sandbox.
if useLandlockWrapper && greywallExePath != "" {
bwrapArgs = append(bwrapArgs, "--ro-bind", greywallExePath, greywallExePath)
}
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up tun2socks and runs the user command
@@ -1067,7 +1094,8 @@ sleep 0.3
// after the main command exits; the user can Ctrl+C to stop it.
// A SIGCHLD trap kills strace once its direct child exits, handling
// the common case of background daemons (LSP servers, watchers).
if opts.Learning && opts.StraceLogPath != "" {
switch {
case opts.Learning && opts.StraceLogPath != "":
innerScript.WriteString(fmt.Sprintf(`# Learning mode: trace filesystem access (foreground for terminal access)
strace -f -qq -I2 -e trace=openat,open,creat,mkdir,mkdirat,unlinkat,renameat,renameat2,symlinkat,linkat -o %s -- %s
GREYWALL_STRACE_EXIT=$?
@@ -1079,7 +1107,7 @@ exit $GREYWALL_STRACE_EXIT
`,
ShellQuoteSingle(opts.StraceLogPath), command,
))
} else if useLandlockWrapper {
case useLandlockWrapper:
// Use Landlock wrapper if available
// Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied
@@ -1100,7 +1128,7 @@ exit $GREYWALL_STRACE_EXIT
// Use exec to replace bash with the wrapper (which will exec the command)
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
} else {
default:
innerScript.WriteString(command)
innerScript.WriteString("\n")
}

View File

@@ -370,7 +370,7 @@ func suggestInstallCmd(features *LinuxFeatures) 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 {
return ""
}

View File

@@ -201,7 +201,7 @@ type LandlockRuleset struct {
func NewLandlockRuleset(debug bool) (*LandlockRuleset, error) {
features := DetectLinuxFeatures()
if !features.CanUseLandlock() {
return nil, fmt.Errorf("Landlock not available (kernel %d.%d, need 5.13+)",
return nil, fmt.Errorf("landlock not available (kernel %d.%d, need 5.13+)",
features.KernelMajor, features.KernelMinor)
}
@@ -438,7 +438,7 @@ func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
// Apply applies the Landlock ruleset to the current process.
func (l *LandlockRuleset) Apply() error {
if !l.initialized {
return fmt.Errorf("Landlock ruleset not initialized")
return fmt.Errorf("landlock ruleset not initialized")
}
// Set NO_NEW_PRIVS first (required for Landlock)

View File

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

View File

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

View File

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

View File

@@ -25,11 +25,11 @@ GREYWALL_BIN="${1:-}"
if [[ -z "$GREYWALL_BIN" ]]; then
if [[ -x "./greywall" ]]; then
GREYWALL_BIN="./greywall"
elif [[ -x "./dis./greywall" ]]; then
GREYWALL_BIN="./dis./greywall"
elif [[ -x "./dist/greywall" ]]; then
GREYWALL_BIN="./dist/greywall"
else
echo "Building greywall..."
go build -o ./greywall ./cm./greywall
go build -o ./greywall ./cmd/greywall
GREYWALL_BIN="./greywall"
fi
fi
@@ -121,7 +121,7 @@ run_test "read file in workspace" "pass" "$GREYWALL_BIN" -c "cat $WORKSPACE/test
# Test: Write outside workspace blocked
# Create a settings file that only allows write to current workspace
SETTINGS_FILE="$WORKSPAC./greywall.json"
SETTINGS_FILE="$WORKSPACE/greywall.json"
cat > "$SETTINGS_FILE" << EOF
{
"filesystem": {

185
scripts/test_install.sh Executable file
View File

@@ -0,0 +1,185 @@
#!/bin/bash
# test_install.sh - Test the install.sh script logic
#
# Tests version detection, URL construction, and error handling
# without requiring a published release.
#
# Usage:
# ./scripts/test_install.sh
#
# Set GREYWALL_TEST_INSTALL_LIVE=1 to also test against a real release
# (requires a published release on Gitea).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_SCRIPT="$SCRIPT_DIR/../install.sh"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
PASSED=0
FAILED=0
SKIPPED=0
pass() { echo -e "Testing: $1... ${GREEN}PASS${NC}"; PASSED=$((PASSED + 1)); }
fail() { echo -e "Testing: $1... ${RED}FAIL${NC} ($2)"; FAILED=$((FAILED + 1)); }
skip() { echo -e "Testing: $1... ${YELLOW}SKIPPED${NC} ($2)"; SKIPPED=$((SKIPPED + 1)); }
echo "Install script: $INSTALL_SCRIPT"
echo "=============================================="
# ============================================================
echo ""
echo "=== Script Sanity ==="
echo ""
# Script exists and is executable
if [[ -f "$INSTALL_SCRIPT" ]]; then
pass "install.sh exists"
else
fail "install.sh exists" "file not found at $INSTALL_SCRIPT"
fi
if sh -n "$INSTALL_SCRIPT" 2>/dev/null; then
pass "install.sh has valid shell syntax"
else
fail "install.sh has valid shell syntax" "syntax error reported by sh -n"
fi
# ============================================================
echo ""
echo "=== Version Detection ==="
echo ""
# No releases → must fail with clean error (not malformed URL garbage)
output=$(sh "$INSTALL_SCRIPT" 2>&1) || true
if echo "$output" | grep -q "Error: Unable to determine version to install"; then
pass "no releases → clean error message"
elif echo "$output" | grep -q "Not found\|null\|undefined"; then
fail "no releases → clean error message" "leaked raw API/HTTP response: $output"
else
# Could be passing if a real release now exists — check if it downloaded correctly
if echo "$output" | grep -q "installed successfully"; then
pass "no releases → clean error message (release exists, install succeeded)"
else
fail "no releases → clean error message" "unexpected output: $output"
fi
fi
# Explicit version arg (v-prefixed) → used as-is
output=$(sh "$INSTALL_SCRIPT" v99.0.0 2>&1) || true
if echo "$output" | grep -q "v99.0.0"; then
pass "explicit version (v-prefixed) passed through"
else
fail "explicit version (v-prefixed) passed through" "version not found in output: $output"
fi
# Explicit version arg (no v prefix) → v added automatically
output=$(sh "$INSTALL_SCRIPT" 99.0.0 2>&1) || true
if echo "$output" | grep -q "v99.0.0"; then
pass "explicit version (no v-prefix) gets v added"
else
fail "explicit version (no v-prefix) gets v added" "output: $output"
fi
# GREYWALL_VERSION env var respected
output=$(GREYWALL_VERSION=99.1.0 sh "$INSTALL_SCRIPT" 2>&1) || true
if echo "$output" | grep -q "v99.1.0"; then
pass "GREYWALL_VERSION env var respected"
else
fail "GREYWALL_VERSION env var respected" "output: $output"
fi
# ============================================================
echo ""
echo "=== URL Construction ==="
echo ""
# Download URL must point to Gitea, not GitHub
output=$(sh "$INSTALL_SCRIPT" v1.2.3 2>&1) || true
if echo "$output" | grep -q "gitea.app.monadical.io"; then
pass "download URL uses Gitea host"
else
fail "download URL uses Gitea host" "output: $output"
fi
if echo "$output" | grep -q "github.com"; then
fail "download URL does not use GitHub" "found github.com in output: $output"
else
pass "download URL does not use GitHub"
fi
# URL contains the version, binary name, OS, and arch
OS_TITLE="Linux"
if [[ "$(uname -s)" == "Darwin" ]]; then OS_TITLE="Darwin"; fi
ARCH="x86_64"
if [[ "$(uname -m)" == "aarch64" || "$(uname -m)" == "arm64" ]]; then ARCH="arm64"; fi
if echo "$output" | grep -q "greywall_1.2.3_${OS_TITLE}_${ARCH}.tar.gz"; then
pass "download URL has correct filename format"
else
fail "download URL has correct filename format" "expected greywall_1.2.3_${OS_TITLE}_${ARCH}.tar.gz in: $output"
fi
# ============================================================
echo ""
echo "=== Error Handling ==="
echo ""
# Non-existent version → curl 404 → clean error, no crash
output=$(sh "$INSTALL_SCRIPT" v0.0.0-nonexistent 2>&1) || true
if echo "$output" | grep -q "Error: Failed to download release"; then
pass "non-existent version → clean download error"
else
fail "non-existent version → clean download error" "output: $output"
fi
# ============================================================
echo ""
echo "=== Live Install (optional) ==="
echo ""
if [[ "${GREYWALL_TEST_INSTALL_LIVE:-}" == "1" ]]; then
# Check a release actually exists before attempting live install
LATEST_TAG=$(curl -s "https://gitea.app.monadical.io/api/v1/repos/monadical/greywall/releases/latest" \
2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || echo "")
case "$LATEST_TAG" in
v[0-9]*)
TMP_BIN=$(mktemp -d)
trap 'rm -rf "$TMP_BIN"' EXIT
install_out=$(HOME="$TMP_BIN" sh "$INSTALL_SCRIPT" 2>&1) || true
if echo "$install_out" | grep -q "installed successfully"; then
if [[ -x "$TMP_BIN/.local/bin/greywall" ]]; then
pass "live install: binary downloaded and executable"
version_out=$("$TMP_BIN/.local/bin/greywall" --version 2>&1)
if echo "$version_out" | grep -qE '^greywall v?[0-9]'; then
pass "live install: binary runs and reports version"
else
fail "live install: binary runs and reports version" "output: $version_out"
fi
else
fail "live install: binary downloaded and executable" "binary not found at $TMP_BIN/.local/bin/greywall"
fi
else
fail "live install: install succeeded" "output: $install_out"
fi
;;
*)
skip "live install (download + run binary)" "no releases published on Gitea yet"
;;
esac
else
skip "live install (download + run binary)" "set GREYWALL_TEST_INSTALL_LIVE=1 to enable"
fi
# ============================================================
echo ""
echo "=============================================="
echo ""
echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}, ${YELLOW}$SKIPPED skipped${NC}"
echo ""
[[ $FAILED -eq 0 ]]