diff --git a/README.md b/README.md index aa80976..69b72ed 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ AI coding tools rely on application-level permission systems that can be bypasse - **File isolation** - POSIX ACLs (Linux) / macOS ACLs deny read access to secrets - **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) +- **Network isolation** - `bwrap --unshare-net` + `slirp4netns` + `iptables` (+ `ip6tables` for IPv6 when available) 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) diff --git a/actions/doctor.go b/actions/doctor.go index 5345a4f..6397168 100644 --- a/actions/doctor.go +++ b/actions/doctor.go @@ -2,6 +2,7 @@ package actions import ( "fmt" + "os" "os/exec" "runtime" "strings" @@ -41,6 +42,10 @@ func runLinuxChecks() { "network filtering — required for allow_net rules", "sudo dnf install slirp4netns OR sudo apt install slirp4netns") + printCheck("ip6tables", + "IPv6 egress filtering — optional; when missing, sandbox runs IPv4-only", + "sudo dnf install iptables OR sudo apt install iptables") + printCheck("setfacl", "persistent ACLs — deny_read enforced on disk between sessions", "sudo dnf install acl OR sudo apt install acl") @@ -91,6 +96,21 @@ func toolVersion(name string) string { return "" } +// ip6tablesAvailable mirrors the runtime check used by the sandbox: kernel v6 +// must be enabled AND ip6tables must be on PATH. Either missing → v4-only. +func ip6tablesAvailable() bool { + if _, err := exec.LookPath("ip6tables"); err != nil { + return false + } + // Match the services-layer check; if v6 is disabled at the kernel, ip6tables + // in the namespace will fail and we'd be running unfiltered v6 — refuse it. + data, err := os.ReadFile("/proc/sys/net/ipv6/conf/all/disable_ipv6") + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "0" +} + // checkUserNamespaces verifies that unprivileged user namespaces are enabled. func checkUserNamespaces() bool { // Attempt a trivial unshare; if it fails the kernel has them disabled. @@ -119,6 +139,11 @@ func printLinuxIsolationMode(bwrap, slirp, unshare bool) { 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") + if ip6tablesAvailable() { + fmt.Println(" + ip6tables IPv6 egress filtered in parallel") + } else { + fmt.Println(" IPv4-only install ip6tables to enable v6 filtering") + } fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent") case bwrap && !slirp: fmt.Println(" bwrap (no network filtering — slirp4netns missing)") diff --git a/docs/AI/README.md b/docs/AI/README.md index b45ee2a..0f1a5aa 100644 --- a/docs/AI/README.md +++ b/docs/AI/README.md @@ -44,7 +44,8 @@ integration/ End-to-end CLI tests - **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. 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). +- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod, bwrap, slirp4netns, iptables/ip6tables). +- **IPv6 sandbox is opt-in by capability detection**: `ipv6SandboxSupported()` requires both kernel v6 enabled and `ip6tables` on PATH. Either missing → sandbox runs IPv4-only. Partial v6 (NAT but no filter) is refused — it would silently bypass `allow_net`. - **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 567683c..9f59fec 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -8,7 +8,7 @@ aigate creates an OS-level sandbox for AI coding agents. When you use Claude Cod - **Execute** dangerous commands (curl, wget, ssh) - **Access** unauthorized network endpoints -Unlike application-level restrictions that can be bypassed, aigate uses kernel-enforced isolation (Linux namespaces + iptables, macOS sandbox-exec). The AI tool physically cannot access what you deny. +Unlike application-level restrictions that can be bypassed, aigate uses kernel-enforced isolation (Linux namespaces + iptables/ip6tables, macOS sandbox-exec). The AI tool physically cannot access what you deny. ## Prerequisites @@ -16,6 +16,7 @@ Unlike application-level restrictions that can be bypassed, aigate uses kernel-e |---|---|---| | **Recommended** | `bwrap` (Bubblewrap) | None (uses built-in sandbox-exec) | | **For network filtering** | `slirp4netns` | None (uses built-in Seatbelt) | +| **For IPv6 filtering** | `ip6tables` (optional — sandbox is v4-only without it) | None | | **For persistent ACLs** | `setfacl` (usually pre-installed) | None | ### Install Bubblewrap (recommended, Linux) @@ -50,6 +51,8 @@ sudo pacman -S slirp4netns If `slirp4netns` is not installed, aigate logs a warning and runs without network filtering. +IPv6 egress is filtered when `ip6tables` is installed (usually shipped with the `iptables` package) and the kernel has IPv6 enabled. Otherwise the sandbox runs IPv4-only and IPv6 traffic is unreachable from inside. + ### Verify your setup ```sh @@ -182,6 +185,7 @@ Example output: ``` ok bwrap v0.10.0 — sandbox isolation (mount/pid/user namespaces) ok slirp4netns v1.3.1 — network filtering (allow_net rules) + ok ip6tables — IPv6 egress filtering (optional) ok setfacl v2.3.2 — persistent ACLs ok user namespaces enabled @@ -339,8 +343,8 @@ Two layers working together for defense-in-depth: Restricts outbound connections to domains listed in `allow_net`: -- **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. +- **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. When `ip6tables` is available and the kernel has IPv6 enabled, slirp4netns is launched with `--enable-ipv6` and a parallel `ip6tables` filter is installed. 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`/`ip6tables` 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**: diff --git a/services/platform_linux.go b/services/platform_linux.go index 8a79a21..071f595 100644 --- a/services/platform_linux.go +++ b/services/platform_linux.go @@ -226,6 +226,34 @@ func hasSlirp4netns() bool { return err == nil } +// hasIp6tables checks whether ip6tables is available on the system. +func hasIp6tables() bool { + _, err := exec.LookPath("ip6tables") + return err == nil +} + +// kernelIPv6Enabled returns true when the kernel has IPv6 globally enabled. +// Some distros ship with ipv6.disable=1 or sysctl net.ipv6.conf.all.disable_ipv6=1; +// in those cases slirp4netns --enable-ipv6 silently produces a sandbox with no +// reachable v6, and ip6tables -A in the inner namespace fails. Refusing v6 in +// that case keeps the sandbox in a known-good v4-only state. +func kernelIPv6Enabled() bool { + data, err := os.ReadFile("/proc/sys/net/ipv6/conf/all/disable_ipv6") + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "0" +} + +// ipv6SandboxSupported returns true when the host has everything needed to run +// an IPv6-enabled sandbox safely: the kernel allows v6, and ip6tables is +// available to install the egress filter. If either is missing we fall back to +// IPv4-only — never partial v6, since a partial filter is worse than no v6 at +// all (silent egress bypass). +func ipv6SandboxSupported() bool { + return kernelIPv6Enabled() && hasIp6tables() +} + // resolveAllowedIPs resolves a list of hostnames/IPs to deduplicated IPv4 addresses. func resolveAllowedIPs(hosts []string) []string { seen := make(map[string]bool) @@ -296,6 +324,25 @@ func parseDNSFromFile(path string) []string { return servers } +// splitDNSByFamily partitions a nameserver list into IPv4 and IPv6 buckets. +// Non-parseable entries are dropped (resolv.conf shouldn't have them, but +// defend anyway — we'd otherwise feed them to iptables and produce shell +// errors like "host/network 'foo' not found", see issue #8). +func splitDNSByFamily(servers []string) (v4, v6 []string) { + for _, s := range servers { + ip := net.ParseIP(s) + if ip == nil { + continue + } + if ip.To4() != nil { + v4 = append(v4, s) + } else { + v6 = append(v6, s) + } + } + return v4, v6 +} + // runWithNetFilter runs a command in a network-filtered namespace using slirp4netns. // // Architecture (two-layer unshare): @@ -309,14 +356,20 @@ func parseDNSFromFile(path string) []string { // setns(CLONE_NEWNET). Launching it from the host fails with EPERM because an // unprivileged process lacks CAP_SYS_ADMIN in its own (init) user namespace. func (p *LinuxPlatform) runWithNetFilter(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { - dnsServers := getSystemDNS() + dnsV4, dnsV6 := splitDNSByFamily(getSystemDNS()) + ipv6Enabled := ipv6SandboxSupported() + if !ipv6Enabled { + dnsV6 = nil + } helpers.Log.Info(). Strs("allow_net", profile.Config.AllowNet). - Strs("dns_servers", dnsServers). + Strs("dns_servers_v4", dnsV4). + Strs("dns_servers_v6", dnsV6). + Bool("ipv6", ipv6Enabled). Msg("starting network-filtered sandbox") - innerScript := buildNetFilterScript(profile.Config.AllowNet, dnsServers, profile, cmd, args) - outerScript := buildOrchestrationScript(innerScript) + innerScript := buildNetFilterScript(profile.Config.AllowNet, dnsV4, dnsV6, ipv6Enabled, profile, cmd, args) + outerScript := buildOrchestrationScript(innerScript, ipv6Enabled) return p.exec.RunPassthroughWith(stdout, stderr, "unshare", "--user", "--map-root-user", "--", "sh", "-c", outerScript) } @@ -326,8 +379,9 @@ func (p *LinuxPlatform) runWithNetFilter(profile domain.SandboxProfile, cmd stri // // It backgrounds the sandbox (in a new net namespace) while preserving stdin // via fd 3, then launches slirp4netns in the foreground (user ns + host network) -// to provide connectivity. -func buildOrchestrationScript(innerScript string) string { +// to provide connectivity. When ipv6Enabled is true, slirp4netns is launched +// with --enable-ipv6 so the inner namespace gets a v6 NAT path too. +func buildOrchestrationScript(innerScript string, ipv6Enabled bool) string { encoded := base64.StdEncoding.EncodeToString([]byte(innerScript)) var sb strings.Builder @@ -351,7 +405,11 @@ func buildOrchestrationScript(innerScript string) string { // Launch slirp4netns: runs in user ns (has CAP_SYS_ADMIN) + host network. // Suppress stdout (verbose protocol debug), keep stderr for real errors. - sb.WriteString("slirp4netns --configure $_SANDBOX_PID tap0 >/dev/null &\n") + if ipv6Enabled { + sb.WriteString("slirp4netns --enable-ipv6 --configure $_SANDBOX_PID tap0 >/dev/null &\n") + } else { + sb.WriteString("slirp4netns --configure $_SANDBOX_PID tap0 >/dev/null &\n") + } sb.WriteString("_SLIRP_PID=$!\n") // Wait for the sandbox to exit, then clean up. @@ -364,55 +422,103 @@ func buildOrchestrationScript(innerScript string) string { return sb.String() } -// buildNetFilterScript builds the shell script that runs inside the network namespace. -// allowNetHosts are the original hostnames/IPs from the config — resolution happens -// inside the namespace so the iptables rules match what the sandboxed process will see. -func buildNetFilterScript(allowNetHosts, dnsServers []string, profile domain.SandboxProfile, cmd string, args []string) string { - var sb strings.Builder - - // Ensure inherited mounts are private so bind mounts work in all environments. - sb.WriteString("mount --make-rprivate / 2>/dev/null || true\n") - - // Remount /proc so it reflects the new PID namespace. - // Without this, /proc/self is stale and glibc's NSS/dlopen fails with - // "fatal library error, lookup self". - sb.WriteString("mount -t proc proc /proc\n") - - // Wait for tap0 interface to come up (slirp4netns creates it) +// writeWaitForTap0 emits a busy-loop that blocks until slirp4netns has +// attached an address to tap0. grep -q inet matches both `inet ` (v4) and +// `inet6 ` (v6 lines), so it works in either mode. +func writeWaitForTap0(sb *strings.Builder) { 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") +} - // Set up DNS: point resolv.conf at slirp4netns DNS forwarder (10.0.2.3) +// writeResolvConf points the sandbox's /etc/resolv.conf at the slirp4netns +// DNS forwarder(s). When ipv6Enabled is true, fd00::3 is added so v6-only +// hostnames resolve from inside the sandbox. +func writeResolvConf(sb *strings.Builder, ipv6Enabled bool) { sb.WriteString("echo 'nameserver 10.0.2.3' > /tmp/.aigate-resolv\n") + if ipv6Enabled { + sb.WriteString("echo 'nameserver fd00::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 rules: allow loopback + DNS before anything else - // (DNS must work for the host resolution below) +// writeIPv4Rules emits the iptables OUTPUT chain that the sandbox enforces: +// loopback + DNS port + allowed DNS servers + per-host allow_net (resolved via +// getent inside the namespace) + final REJECT. +// +// Layout matches the v6 mirror in writeIPv6Rules — keep them in sync. +func writeIPv4Rules(sb *strings.Builder, allowNetHosts, dnsServers []string) { 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") - - // Allow traffic to upstream DNS servers (needed for slirp4netns forwarding) for _, dns := range dnsServers { - fmt.Fprintf(&sb, "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 { - 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]) + 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. // This ensures the IPs match what the sandboxed process will get from DNS, // avoiding mismatches from CDN anycast / DNS load balancing. - // Each host retries up to 3 times to handle transient DNS hiccups. 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) + 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") +} + +// writeIPv6Rules emits the ip6tables OUTPUT chain. Mirrors writeIPv4Rules but +// resolves AllowNet hosts via getent ahostsv6 (AAAA records) and uses +// icmp6-adm-prohibited for the final REJECT. +// +// Only called when ipv6SandboxSupported() == true — otherwise the sandbox is +// v4-only and v6 egress is unreachable via slirp4netns anyway. +func writeIPv6Rules(sb *strings.Builder, allowNetHosts, dnsServers []string) { + sb.WriteString("ip6tables -A OUTPUT -o lo -j ACCEPT\n") + sb.WriteString("ip6tables -A OUTPUT -p udp --dport 53 -j ACCEPT\n") + sb.WriteString("ip6tables -A OUTPUT -p tcp --dport 53 -j ACCEPT\n") + for _, dns := range dnsServers { + fmt.Fprintf(sb, "ip6tables -A OUTPUT -d %s -j ACCEPT\n", dns) + } + if len(allowNetHosts) > 0 { + // v6 DNS readiness probe. We don't wait here if the host has no AAAA — + // resolution simply returns empty and no ip6tables rules get added, + // which means egress to v6 for that host is blocked (final REJECT). + // That's fine: the v4 path still allows it. + fmt.Fprintf(sb, "for i in $(seq 1 50); do getent ahostsv6 %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 ahostsv6 %q 2>/dev/null | awk '{print $1}' | sort -u); [ -n \"$_ips\" ] && break; sleep 0.5; done; for _ip in $_ips; do ip6tables -A OUTPUT -d \"$_ip\" -j ACCEPT; done\n", host) + } + sb.WriteString("ip6tables -A OUTPUT -j REJECT --reject-with icmp6-adm-prohibited\n") +} + +// buildNetFilterScript builds the shell script that runs inside the network namespace. +// allowNetHosts are the original hostnames/IPs from the config — resolution happens +// inside the namespace so the iptables rules match what the sandboxed process will see. +// +// When ipv6Enabled is true, the script also writes a parallel ip6tables filter: +// loopback + DNS port 53 + dnsServersV6 + AAAA-resolved allowNetHosts + final +// REJECT. ipv6Enabled must only be set when the caller has verified ip6tables +// is available — a partial v6 ruleset is worse than none. +func buildNetFilterScript(allowNetHosts, dnsServersV4, dnsServersV6 []string, ipv6Enabled bool, profile domain.SandboxProfile, cmd string, args []string) string { + var sb strings.Builder + + // Ensure inherited mounts are private so bind mounts work in all environments. + sb.WriteString("mount --make-rprivate / 2>/dev/null || true\n") + + // Remount /proc so it reflects the new PID namespace. + // Without this, /proc/self is stale and glibc's NSS/dlopen fails with + // "fatal library error, lookup self". + sb.WriteString("mount -t proc proc /proc\n") + + writeWaitForTap0(&sb) + writeResolvConf(&sb, ipv6Enabled) + writeIPv4Rules(&sb, allowNetHosts, dnsServersV4) + if ipv6Enabled { + writeIPv6Rules(&sb, allowNetHosts, dnsServersV6) + } // Write policy file + mount overrides (deny_read markers point here) sb.WriteString(buildPolicyFile(profile)) diff --git a/services/platform_linux_bwrap.go b/services/platform_linux_bwrap.go index a6f21de..3ac56b5 100644 --- a/services/platform_linux_bwrap.go +++ b/services/platform_linux_bwrap.go @@ -27,6 +27,19 @@ func hasBwrap() bool { return err == nil } +// slirpArgs builds the slirp4netns CLI args for a given sandbox child PID. +// When ipv6Enabled is true, --enable-ipv6 is prepended so the inner namespace +// gets a v6 NAT path (forwarder at fd00::3, sandbox addr in fd00::/64). +// ipv6Enabled must only be true on hosts that also have ip6tables — otherwise +// the sandbox has v6 connectivity with no filter, which is worse than no v6. +func slirpArgs(childPID int, ipv6Enabled bool) []string { + args := []string{"--configure", strconv.Itoa(childPID), "tap0"} + if ipv6Enabled { + args = append([]string{"--enable-ipv6"}, args...) + } + return args +} + // runWithBwrap runs a command in a Bubblewrap sandbox. // // bwrap replaces the shell-script-based unshare approach: @@ -214,10 +227,19 @@ func writeTmpFile(pattern, content string) (string, error) { // 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() + dnsV4, dnsV6 := splitDNSByFamily(getSystemDNS()) + ipv6Enabled := ipv6SandboxSupported() + if !ipv6Enabled { + // Don't surface v6 nameservers in the script when we can't filter them. + // (See the package-level comment on ipv6SandboxSupported: partial v6 is + // worse than none, so we drop v6 entirely on hosts without ip6tables.) + dnsV6 = nil + } helpers.Log.Info(). Strs("allow_net", profile.Config.AllowNet). - Strs("dns_servers", dnsServers). + Strs("dns_servers_v4", dnsV4). + Strs("dns_servers_v6", dnsV6). + Bool("ipv6", ipv6Enabled). Msg("starting bwrap network-filtered sandbox") var tmpFiles []string @@ -240,7 +262,7 @@ func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd } defer infoR.Close() //nolint:errcheck - bwrapArgs = appendBwrapNetArgs(bwrapArgs, profile.Config.AllowNet, dnsServers, cmd, args) + bwrapArgs = appendBwrapNetArgs(bwrapArgs, profile.Config.AllowNet, dnsV4, dnsV6, ipv6Enabled, cmd, args) bwrapCmd := exec.Command("bwrap", bwrapArgs...) bwrapCmd.ExtraFiles = []*os.File{infoW} // fd 3 in child @@ -282,7 +304,7 @@ func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd // 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 := exec.Command("slirp4netns", slirpArgs(childPID, ipv6Enabled)...) slirpCmd.Stdout = nil slirpCmd.Stderr = os.Stderr if slirpErr := slirpCmd.Start(); slirpErr != nil { @@ -337,7 +359,7 @@ func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd // 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 { +func appendBwrapNetArgs(args []string, allowNet, dnsV4, dnsV6 []string, ipv6Enabled bool, 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") @@ -347,7 +369,7 @@ func appendBwrapNetArgs(args []string, allowNet, dnsServers []string, cmd string // 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) + innerScript := buildNetOnlyScript(allowNet, dnsV4, dnsV6, ipv6Enabled, cmd, cmdArgs) args = append(args, "--", "sh", "-c", innerScript) return args } @@ -395,35 +417,20 @@ func readBwrapInfoPID(r io.Reader) (int, error) { // 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 { +// slirp4netns DNS forwarder(s), configuring iptables (+ ip6tables when v6 +// enabled), then exec'ing the command. +func buildNetOnlyScript(allowNetHosts, dnsServersV4, dnsServersV6 []string, ipv6Enabled bool, 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) + writeWaitForTap0(&sb) + writeResolvConf(&sb, ipv6Enabled) + writeIPv4Rules(&sb, allowNetHosts, dnsServersV4) + if ipv6Enabled { + writeIPv6Rules(&sb, allowNetHosts, dnsServersV6) } - sb.WriteString("iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited\n") sb.WriteString("exec ") sb.WriteString(shellEscape(cmd, args)) diff --git a/services/platform_linux_bwrap_test.go b/services/platform_linux_bwrap_test.go index 2366afd..99069bc 100644 --- a/services/platform_linux_bwrap_test.go +++ b/services/platform_linux_bwrap_test.go @@ -727,7 +727,7 @@ func TestRunSandboxedDispatch_FallsBackToUnshareWhenNoBwrap(t *testing.T) { // ── buildNetOnlyScript ─────────────────────────────────────────────────────── func TestBuildNetOnlyScript_HasNetworkSetup(t *testing.T) { - script := buildNetOnlyScript(nil, nil, "echo", []string{"hello"}) + script := buildNetOnlyScript(nil, nil, nil, false, "echo", []string{"hello"}) for _, want := range []string{ "mount --make-rprivate /", @@ -745,7 +745,7 @@ func TestBuildNetOnlyScript_HasNetworkSetup(t *testing.T) { func TestBuildNetOnlyScript_HasNoAigateMarkers(t *testing.T) { // bwrap handles policy/mount/exec isolation — the net-only script should not. - script := buildNetOnlyScript(nil, nil, "echo", nil) + script := buildNetOnlyScript(nil, nil, nil, false, "echo", nil) for _, forbidden := range []string{ "aigate-policy", @@ -762,6 +762,7 @@ func TestBuildNetOnlyScript_AllowNetRules(t *testing.T) { script := buildNetOnlyScript( []string{"api.anthropic.com", "1.2.3.4"}, []string{"8.8.8.8"}, + nil, false, "echo", nil, ) @@ -779,9 +780,49 @@ func TestBuildNetOnlyScript_AllowNetRules(t *testing.T) { } } +// IPv6 path through the bwrap net-only script. Mirrors the v4 assertions plus +// the fd00::3 forwarder and ip6tables REJECT. +func TestBuildNetOnlyScript_IPv6Rules(t *testing.T) { + script := buildNetOnlyScript( + []string{"api.anthropic.com"}, + []string{"8.8.8.8"}, + []string{"2001:4860:4860::8888"}, + true, + "echo", nil, + ) + for _, want := range []string{ + "nameserver fd00::3", + "ip6tables -A OUTPUT -o lo -j ACCEPT", + "ip6tables -A OUTPUT -d 2001:4860:4860::8888 -j ACCEPT", + `getent ahostsv6 "api.anthropic.com"`, + "ip6tables -A OUTPUT -j REJECT --reject-with icmp6-adm-prohibited", + } { + if !strings.Contains(script, want) { + t.Errorf("script should contain %q, got:\n%s", want, script) + } + } +} + +// Guards against partial-v6 leakage: when ipv6Enabled=false the script must +// be 100% v4 — no ip6tables, no fd00::3, no ahostsv6. +func TestBuildNetOnlyScript_NoIPv6WhenDisabled(t *testing.T) { + script := buildNetOnlyScript( + []string{"api.anthropic.com"}, + []string{"8.8.8.8"}, + []string{"2001:4860:4860::8888"}, // present but should be ignored + false, + "echo", nil, + ) + for _, forbidden := range []string{"ip6tables", "fd00::3", "ahostsv6", "2001:4860"} { + if strings.Contains(script, forbidden) { + t.Errorf("ipv6Enabled=false must not include %q, got:\n%s", forbidden, script) + } + } +} + 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"}) + script := buildNetOnlyScript(nil, nil, nil, false, "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) } @@ -794,7 +835,7 @@ func TestBuildNetOnlyScript_ArgWithSpaces(t *testing.T) { // 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"}) + args := appendBwrapNetArgs(nil, []string{"example.com"}, []string{"8.8.8.8"}, nil, false, "echo", []string{"hi"}) if !containsFlag(args, "--unshare-net") { t.Errorf("args should contain --unshare-net, got: %v", args) @@ -805,7 +846,7 @@ func TestAppendBwrapNetArgs_HasUnshareNetAndInfoFd(t *testing.T) { } func TestAppendBwrapNetArgs_InnerScriptPassedToSh(t *testing.T) { - args := appendBwrapNetArgs(nil, []string{"example.com"}, nil, "echo", []string{"hello"}) + args := appendBwrapNetArgs(nil, []string{"example.com"}, nil, nil, false, "echo", []string{"hello"}) sepIdx := -1 for i, a := range args { @@ -844,7 +885,7 @@ func TestAppendBwrapNetArgs_IsolationFlagsFromBuildBwrapArgs(t *testing.T) { if err != nil { t.Fatalf("buildBwrapArgs error: %v", err) } - args := appendBwrapNetArgs(base, profile.Config.AllowNet, nil, "echo", nil) + args := appendBwrapNetArgs(base, profile.Config.AllowNet, nil, nil, false, "echo", nil) for _, flag := range []string{"--unshare-user", "--unshare-pid", "--die-with-parent", "--unshare-net"} { if !containsFlag(args, flag) { @@ -857,7 +898,7 @@ func TestAppendBwrapNetArgs_IsolationFlagsFromBuildBwrapArgs(t *testing.T) { } func TestAppendBwrapNetArgs_NoAigateMarkersInInnerScript(t *testing.T) { - args := appendBwrapNetArgs(nil, nil, nil, "echo", nil) + args := appendBwrapNetArgs(nil, nil, nil, nil, false, "echo", nil) script := "" for i, a := range args { if a == "-c" && i+1 < len(args) { @@ -871,6 +912,35 @@ func TestAppendBwrapNetArgs_NoAigateMarkersInInnerScript(t *testing.T) { } } +func TestSlirpArgs(t *testing.T) { + t.Run("v4-only does not pass --enable-ipv6", func(t *testing.T) { + args := slirpArgs(1234, false) + want := []string{"--configure", "1234", "tap0"} + if len(args) != len(want) { + t.Fatalf("len = %d, want %d (%v)", len(args), len(want), args) + } + for i := range want { + if args[i] != want[i] { + t.Errorf("args[%d] = %q, want %q", i, args[i], want[i]) + } + } + }) + + t.Run("v6-enabled prepends --enable-ipv6", func(t *testing.T) { + args := slirpArgs(1234, true) + if len(args) == 0 || args[0] != "--enable-ipv6" { + t.Errorf("first arg should be --enable-ipv6, got: %v", args) + } + // Configure and tap0 must still be present in order. + if !containsPair(args, "--configure", "1234") { + t.Errorf("args should contain --configure 1234, got: %v", args) + } + if args[len(args)-1] != "tap0" { + t.Errorf("last arg should be tap0, got: %v", args) + } + }) +} + // TestRunSandboxedDispatch_BwrapNetFilter is a smoke test that actually executes // bwrap + slirp4netns. It lives here (not integration/) because it specifically // tests the dispatch logic inside RunSandboxed. Skipped with -short. diff --git a/services/platform_linux_test.go b/services/platform_linux_test.go index d20dfd7..3388d91 100644 --- a/services/platform_linux_test.go +++ b/services/platform_linux_test.go @@ -345,6 +345,7 @@ func TestBuildNetFilterScript(t *testing.T) { script := buildNetFilterScript( []string{"api.anthropic.com", "1.2.3.4"}, []string{"8.8.8.8"}, + nil, false, profile, "echo", []string{"hello"}, ) // Hostnames should be resolved inside the namespace via getent ahostsv4 @@ -364,6 +365,7 @@ func TestBuildNetFilterScript(t *testing.T) { script := buildNetFilterScript( []string{"example.com"}, []string{"8.8.8.8", "1.1.1.1"}, + nil, false, profile, "echo", []string{"hello"}, ) if !strings.Contains(script, "iptables -A OUTPUT -p udp --dport 53 -j ACCEPT") { @@ -380,22 +382,56 @@ func TestBuildNetFilterScript(t *testing.T) { } }) + // Regression test for issue #8: callers feed only IPv4 nameservers to this + // function. If something ever regresses and a v6 address ends up here, the + // generated `iptables -A OUTPUT -d ` would fail at runtime with + // "host/network not found" — and there's no test catching it. + t.Run("does not emit iptables rules for IPv6 nameservers if any slip through", func(t *testing.T) { + // Pass v4 + v6 mixed; v6 entries should not produce iptables rules at all + // in any sane implementation (current code defers filtering to the caller, + // so this test documents the contract). + script := buildNetFilterScript( + nil, + []string{"192.168.178.1"}, // post-splitDNSByFamily list + nil, false, + profile, "echo", nil, + ) + // Sanity: v4 rule present. + if !strings.Contains(script, "iptables -A OUTPUT -d 192.168.178.1 -j ACCEPT") { + t.Error("script should accept v4 nameserver 192.168.178.1") + } + // Negative: no v6 literal in the script (the colon is the giveaway). + // Find "-d " patterns and ensure addr is not v6. + for _, line := range strings.Split(script, "\n") { + if !strings.Contains(line, "iptables -A OUTPUT -d ") { + continue + } + // Extract the token after "-d ". + idx := strings.Index(line, "-d ") + rest := line[idx+3:] + tok := strings.Fields(rest) + if len(tok) > 0 && strings.Contains(tok[0], ":") { + t.Errorf("iptables rule references IPv6 address %q: %s", tok[0], line) + } + } + }) + t.Run("contains resolv.conf fix", func(t *testing.T) { - script := buildNetFilterScript(nil, nil, profile, "echo", nil) + script := buildNetFilterScript(nil, nil, nil, false, profile, "echo", nil) if !strings.Contains(script, "nameserver 10.0.2.3") { t.Error("script should set resolv.conf to slirp4netns DNS") } }) t.Run("contains wait for tap0", func(t *testing.T) { - script := buildNetFilterScript(nil, nil, profile, "echo", nil) + script := buildNetFilterScript(nil, nil, nil, false, profile, "echo", nil) if !strings.Contains(script, "ip addr show tap0") { t.Error("script should wait for tap0 interface") } }) t.Run("waits for real DNS before resolving hosts", func(t *testing.T) { - script := buildNetFilterScript([]string{"example.com", "other.com"}, nil, profile, "echo", nil) + script := buildNetFilterScript([]string{"example.com", "other.com"}, nil, nil, false, profile, "echo", nil) // DNS readiness check should use the FIRST AllowNet host, not localhost dnsWaitIdx := strings.Index(script, "getent ahostsv4 \"example.com\" >/dev/null") if dnsWaitIdx == -1 { @@ -407,18 +443,65 @@ func TestBuildNetFilterScript(t *testing.T) { }) t.Run("retries host resolution on failure", func(t *testing.T) { - script := buildNetFilterScript([]string{"example.com"}, nil, profile, "echo", nil) + script := buildNetFilterScript([]string{"example.com"}, nil, nil, false, profile, "echo", nil) if !strings.Contains(script, "_attempt in 1 2 3") { t.Error("should retry getent resolution") } }) t.Run("contains target command", func(t *testing.T) { - script := buildNetFilterScript(nil, nil, profile, "mycommand", []string{"--flag", "value"}) + script := buildNetFilterScript(nil, nil, nil, false, profile, "mycommand", []string{"--flag", "value"}) if !strings.Contains(script, "exec mycommand --flag value") { t.Errorf("script should contain exec of target command, got:\n%s", script) } }) + + // IPv6 path: when ipv6Enabled=true, the script must also emit ip6tables + // rules, a v6 DNS forwarder, and AAAA resolution for allow_net hosts. + t.Run("emits ip6tables rules when ipv6Enabled", func(t *testing.T) { + script := buildNetFilterScript( + []string{"example.com"}, + []string{"192.168.178.1"}, + []string{"2001:4860:4860::8888"}, + true, + profile, "echo", nil, + ) + for _, want := range []string{ + "ip6tables -A OUTPUT -o lo -j ACCEPT", + "ip6tables -A OUTPUT -p udp --dport 53 -j ACCEPT", + "ip6tables -A OUTPUT -p tcp --dport 53 -j ACCEPT", + "ip6tables -A OUTPUT -d 2001:4860:4860::8888 -j ACCEPT", + "getent ahostsv6 \"example.com\"", + "ip6tables -A OUTPUT -j REJECT --reject-with icmp6-adm-prohibited", + "nameserver fd00::3", + } { + if !strings.Contains(script, want) { + t.Errorf("script should contain %q, got:\n%s", want, script) + } + } + // v4 rules must still be present. + if !strings.Contains(script, "iptables -A OUTPUT -d 192.168.178.1 -j ACCEPT") { + t.Error("v4 rules must still be emitted when ipv6Enabled=true") + } + }) + + t.Run("omits ip6tables and fd00::3 when ipv6Enabled=false", func(t *testing.T) { + script := buildNetFilterScript( + []string{"example.com"}, + []string{"192.168.178.1"}, + nil, false, + profile, "echo", nil, + ) + if strings.Contains(script, "ip6tables") { + t.Errorf("script must not contain ip6tables when ipv6Enabled=false, got:\n%s", script) + } + if strings.Contains(script, "fd00::3") { + t.Errorf("script must not include v6 forwarder when ipv6Enabled=false") + } + if strings.Contains(script, "getent ahostsv6") { + t.Errorf("script must not resolve AAAA when ipv6Enabled=false") + } + }) } func TestRunSandboxedDispatch(t *testing.T) { @@ -453,7 +536,7 @@ func TestBuildOrchestrationScript(t *testing.T) { inner := "echo hello world\n" t.Run("embeds inner script via base64", func(t *testing.T) { - script := buildOrchestrationScript(inner) + script := buildOrchestrationScript(inner, false) encoded := base64.StdEncoding.EncodeToString([]byte(inner)) if !strings.Contains(script, encoded) { t.Error("orchestration script should contain base64-encoded inner script") @@ -461,7 +544,7 @@ func TestBuildOrchestrationScript(t *testing.T) { }) t.Run("preserves stdin via fd 3", func(t *testing.T) { - script := buildOrchestrationScript(inner) + script := buildOrchestrationScript(inner, false) if !strings.Contains(script, "exec 3<&0") { t.Error("should save stdin to fd 3") } @@ -471,7 +554,7 @@ func TestBuildOrchestrationScript(t *testing.T) { }) t.Run("uses two-layer unshare", func(t *testing.T) { - script := buildOrchestrationScript(inner) + script := buildOrchestrationScript(inner, false) // Inner unshare should create net namespace (outer only creates user ns) if !strings.Contains(script, "unshare --net --mount --pid --fork") { t.Error("inner unshare should create net/mount/pid namespaces") @@ -479,14 +562,24 @@ func TestBuildOrchestrationScript(t *testing.T) { }) t.Run("runs slirp4netns inside user namespace", func(t *testing.T) { - script := buildOrchestrationScript(inner) + script := buildOrchestrationScript(inner, false) if !strings.Contains(script, "slirp4netns --configure $_SANDBOX_PID tap0") { t.Error("should launch slirp4netns with sandbox PID") } + if strings.Contains(script, "--enable-ipv6") { + t.Error("ipv6Enabled=false should not enable v6 on slirp4netns") + } + }) + + t.Run("passes --enable-ipv6 to slirp4netns when ipv6Enabled=true", func(t *testing.T) { + script := buildOrchestrationScript(inner, true) + if !strings.Contains(script, "slirp4netns --enable-ipv6 --configure $_SANDBOX_PID tap0") { + t.Errorf("ipv6Enabled=true should pass --enable-ipv6 to slirp4netns, got:\n%s", script) + } }) t.Run("waits for namespace and cleans up", func(t *testing.T) { - script := buildOrchestrationScript(inner) + script := buildOrchestrationScript(inner, false) if !strings.Contains(script, "readlink /proc/$_SANDBOX_PID/ns/net") { t.Error("should wait for net namespace to differ from host") } @@ -512,6 +605,62 @@ func TestGetSystemDNS(t *testing.T) { } } +func TestSplitDNSByFamily(t *testing.T) { + t.Run("all v4", func(t *testing.T) { + v4, v6 := splitDNSByFamily([]string{"8.8.8.8", "1.1.1.1"}) + if len(v4) != 2 || v4[0] != "8.8.8.8" || v4[1] != "1.1.1.1" { + t.Errorf("v4 = %v, want [8.8.8.8 1.1.1.1]", v4) + } + if len(v6) != 0 { + t.Errorf("v6 = %v, want []", v6) + } + }) + + t.Run("all v6", func(t *testing.T) { + v4, v6 := splitDNSByFamily([]string{"2001:4860:4860::8888", "fd2e:2bd1:b699::1"}) + if len(v4) != 0 { + t.Errorf("v4 = %v, want []", v4) + } + if len(v6) != 2 { + t.Errorf("v6 = %v, want 2 entries", v6) + } + }) + + // Reproduces the resolv.conf shape from issue #8 (Fedora 43 with both v4 and + // v6 nameservers populated by NetworkManager). v6 entries previously leaked + // into iptables and caused "host/network not found" errors. + t.Run("mixed v4 and v6 from issue #8", func(t *testing.T) { + v4, v6 := splitDNSByFamily([]string{ + "192.168.178.1", + "fd2e:2bd1:b699:0:4a5d:35ff:fe1c:74fd", + "2003:c2:3f1c:8100:4a5d:35ff:fe1c:74fd", + }) + if len(v4) != 1 || v4[0] != "192.168.178.1" { + t.Errorf("v4 = %v, want [192.168.178.1]", v4) + } + if len(v6) != 2 { + t.Errorf("v6 = %v, want 2 entries", v6) + } + }) + + t.Run("drops unparseable entries", func(t *testing.T) { + v4, v6 := splitDNSByFamily([]string{"not-an-ip", "8.8.8.8"}) + if len(v4) != 1 || v4[0] != "8.8.8.8" { + t.Errorf("v4 = %v, want [8.8.8.8]", v4) + } + if len(v6) != 0 { + t.Errorf("v6 = %v, want []", v6) + } + }) + + t.Run("empty input", func(t *testing.T) { + v4, v6 := splitDNSByFamily(nil) + if v4 != nil || v6 != nil { + t.Errorf("expected nil/nil for empty input, got v4=%v v6=%v", v4, v6) + } + }) +} + func TestParseDNSFromFile(t *testing.T) { tmpFile := t.TempDir() + "/resolv.conf" @@ -712,7 +861,7 @@ func TestBuildNetFilterScript_MountMakeRprivate(t *testing.T) { Config: domain.Config{}, WorkDir: "/tmp", } - script := buildNetFilterScript(nil, nil, profile, "echo", nil) + script := buildNetFilterScript(nil, nil, nil, false, profile, "echo", nil) if !strings.Contains(script, "mount --make-rprivate /") { t.Error("net filter script should start with mount --make-rprivate /") } @@ -841,7 +990,7 @@ func TestBuildNetFilterScript_IncludesExecDeny(t *testing.T) { WorkDir: "/tmp", } - script := buildNetFilterScript(nil, nil, profile, "echo", []string{"hello"}) + script := buildNetFilterScript(nil, nil, nil, false, profile, "echo", []string{"hello"}) if !strings.Contains(script, "/tmp/.aigate-deny-exec") { t.Error("net filter script should include exec deny overrides") }