3 Commits

Author SHA1 Message Date
a04f5feee2 fix: prevent learning mode from collapsing read paths to $HOME
Files directly under ~ (e.g., ~/.gitignore, ~/.npmrc) were collapsed
to the home directory, defeating sandboxing. Now keeps exact file paths
when the parent directory would be $HOME.
2026-02-13 11:38:51 -06:00
c95fca830b docs: add Linux deny-by-default lessons to experience.md
Document three issues encountered during --tmpfs / isolation:
symlinked system dirs on merged-usr distros, Landlock denying
reads on bind-mounted /dev/null, and mandatory deny paths
overriding sensitive file masks.
2026-02-12 20:16:37 -06:00
5affaf77a5 feat: deny-by-default filesystem isolation
Flip the sandbox from allow-by-default reads (--ro-bind / /) to
deny-by-default (--tmpfs / with selective mounts). This makes the
sandbox safer by default — only system paths, CWD, and explicitly
allowed paths are accessible.

- Config: DefaultDenyRead is now *bool (nil = true, deny-by-default)
  with IsDefaultDenyRead() helper; opt out via "defaultDenyRead": false
- Linux: new buildDenyByDefaultMounts() using --tmpfs / + selective
  --ro-bind for system paths, --symlink for merged-usr distros (Arch),
  --bind for CWD, and --ro-bind for user tooling/shell configs/caches
- macOS: generateReadRules() adds CWD subpath, ancestor traversal,
  home shell configs/caches; generateWriteRules() auto-allows CWD
- Landlock: deny-by-default mode allows only specific user tooling
  paths instead of blanket home directory read access
- Sensitive .env files masked within CWD via empty-file overlay on
  Linux and deny rules on macOS
- Learning templates now include allowRead and .env deny patterns
2026-02-12 20:15:40 -06:00
6 changed files with 6 additions and 299 deletions

View File

@@ -1,101 +0,0 @@
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

View File

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

View File

@@ -1,62 +0,0 @@
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,10 +1,5 @@
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
@@ -47,7 +42,7 @@ checksum:
changelog:
sort: asc
use: gitea
use: github
format: "{{ .SHA }}: {{ .Message }}{{ with .AuthorUsername }} (@{{ . }}){{ end }}"
filters:
exclude:
@@ -81,7 +76,7 @@ changelog:
order: 9999
release:
gitea:
github:
owner: monadical
name: greywall
draft: false

View File

@@ -519,14 +519,8 @@ 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 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) {
// Create intermediary dirs if needed
for _, dir := range intermediaryDirs("/", p) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}
@@ -539,11 +533,7 @@ 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
dirTarget := normalized
if !isDirectory(normalized) {
dirTarget = filepath.Dir(normalized)
}
for _, dir := range intermediaryDirs("/", dirTarget) {
for _, dir := range intermediaryDirs("/", normalized) {
if !isSystemMountPoint(dir) {
args = append(args, "--dir", dir)
}

View File

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