From eeef67a10b1d59c00d265b6b61b8afaf1a689f04 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Tue, 24 Mar 2026 09:22:16 +0100 Subject: [PATCH 1/8] feat: migrate Linux sandbox to bwrap, add doctor command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — non-network path (runWithBwrap): - Replaces unshare shell-script approach with declarative bwrap bind mounts - deny_read: --bind deny-marker / --tmpfs over files and dirs - deny_exec: --bind deny-stub / wrapper scripts over binary paths - Config protection: --tmpfs over ~/.aigate - Resolves symlinks for bind destinations (bwrap requirement) Phase 2 — network-filtered path (runWithBwrapNetFilter): - Uses bwrap --unshare-net + --info-fd to get child PID without nested unshare - Launches slirp4netns from host side after reading child PID from info-fd - Adds --uid 0 --gid 0 --cap-add cap_net_admin,cap_sys_admin so iptables works inside bwrap (bwrap drops all caps by default; nf_tables requires uid 0) - Fixes shellEscape: args with spaces/metacharacters now correctly single-quoted Phase 3 — aigate doctor command: - Checks bwrap, slirp4netns, setfacl, user namespaces - Shows version + path for each tool - Reports which isolation mode will be active (4 Linux variants + macOS) README updates: - Add bwrap to prerequisites with distro install instructions - Add doctor to TL;DR quick-start and command reference - Update How It Works to reflect bwrap + info-fd architecture - Update process isolation section to describe bwrap vs unshare fallback - Add troubleshooting entry for missing bwrap - Update docs/AI/README.md architecture section Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 +- actions/doctor.go | 162 ++++++ docs/AI/README.md | 19 +- docs/user/README.md | 66 ++- integration/cli_test.go | 19 + main.go | 10 +- services/platform_linux.go | 28 +- services/platform_linux_bwrap.go | 494 ++++++++++++++++ services/platform_linux_bwrap_test.go | 777 ++++++++++++++++++++++++++ services/platform_linux_test.go | 88 +-- 10 files changed, 1613 insertions(+), 60 deletions(-) create mode 100644 actions/doctor.go create mode 100644 services/platform_linux_bwrap.go create mode 100644 services/platform_linux_bwrap_test.go 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/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/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..1c47be6 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) } @@ -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..3c6aca4 --- /dev/null +++ b/services/platform_linux_bwrap.go @@ -0,0 +1,494 @@ +//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{ + "--bind", "/", "/", // full filesystem (private mount namespace) + "--dev", "/dev", // minimal private /dev + "--proc", "/proc", // fresh /proc for PID namespace + "--unshare-pid", // new PID namespace + "--unshare-user", // user namespace (current UID → root inside) + "--die-with-parent", + } + + // Hide aigate config directory from the sandboxed process. + if home, err := os.UserHomeDir(); err == nil { + 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. + 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_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 { + sb.WriteString(fmt.Sprintf("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 { + sb.WriteString(fmt.Sprintf("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 { + sb.WriteString(fmt.Sprintf("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() + 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() + + 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() // close write end in parent after Start (child has its own copy) + if err != nil { + infoR.Close() + 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() + if err != nil { + bwrapCmd.Process.Kill() //nolint:errcheck + bwrapCmd.Wait() //nolint:errcheck + if ptm != nil { + ptm.Close() + } + 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() + } + 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() + } + 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 { + sb.WriteString(fmt.Sprintf("iptables -A OUTPUT -d %s -j ACCEPT\n", dns)) + } + 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])) + } + 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)) + } + 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() +} + +// 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 { + if binPath, lookErr := exec.LookPath(cmd); lookErr == nil { + destPath := resolveForBwrap(binPath) + 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 { + caseArms.WriteString(fmt.Sprintf("%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..b52399f --- /dev/null +++ b/services/platform_linux_bwrap_test.go @@ -0,0 +1,777 @@ +//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) + + 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) + + p2, err := writeTmpFile("aigate-test-*", "b") + if err != nil { + t.Fatalf("second writeTmpFile() error = %v", err) + } + defer os.Remove(p2) + + 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) + } +} + +func TestBuildBwrapArgs_FullFilesystem(t *testing.T) { + profile := domain.SandboxProfile{Config: domain.Config{}, WorkDir: "/tmp"} + args, tmp := setupBwrapArgsTest(t, profile) + defer cleanupTmp(tmp) + + if !containsTriple(args, "--bind", "/", "/") { + t.Errorf("args should contain --bind / /, 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]) + } +} + +// ── 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