From 6f06970b071ad392cb898d8e2b8b411d8e92bbbb Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Thu, 21 May 2026 20:45:44 +0200 Subject: [PATCH 1/3] fix: drop IPv6 nameservers before feeding iptables (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts with both v4 and v6 nameservers in resolv.conf (Fedora 43 + IPv6 routers being a common case) caused the sandbox startup script to run `iptables -A OUTPUT -d `, which iptables rejects with "host/network not found". Sandbox still worked (v4 path was unaffected), but two error lines printed at every launch. Split the resolv.conf nameserver list by address family and only feed the v4 entries to iptables. v6 entries are logged but otherwise dropped — the sandbox is IPv4-only by design (slirp4netns IPv4 NAT only, iptables-only rules), so those nameservers were never reachable from inside the sandbox anyway. This is the symptom fix. Adding actual IPv6 sandbox support is the follow-up commit on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/platform_linux.go | 29 +++++++++-- services/platform_linux_bwrap.go | 10 ++-- services/platform_linux_test.go | 89 ++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/services/platform_linux.go b/services/platform_linux.go index 8a79a21..631adc0 100644 --- a/services/platform_linux.go +++ b/services/platform_linux.go @@ -296,6 +296,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,13 +328,17 @@ 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()) helpers.Log.Info(). Strs("allow_net", profile.Config.AllowNet). - Strs("dns_servers", dnsServers). + Strs("dns_servers_v4", dnsV4). + Strs("dns_servers_v6", dnsV6). Msg("starting network-filtered sandbox") - innerScript := buildNetFilterScript(profile.Config.AllowNet, dnsServers, profile, cmd, args) + // IPv6 nameservers are dropped: the sandbox is IPv4-only (slirp4netns NAT, + // iptables-only rules). Feeding v6 addrs to iptables -d produces + // "host/network not found" errors. See issue #8. + innerScript := buildNetFilterScript(profile.Config.AllowNet, dnsV4, profile, cmd, args) outerScript := buildOrchestrationScript(innerScript) return p.exec.RunPassthroughWith(stdout, stderr, "unshare", "--user", "--map-root-user", "--", "sh", "-c", outerScript) diff --git a/services/platform_linux_bwrap.go b/services/platform_linux_bwrap.go index a6f21de..fa72037 100644 --- a/services/platform_linux_bwrap.go +++ b/services/platform_linux_bwrap.go @@ -214,10 +214,11 @@ 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()) helpers.Log.Info(). Strs("allow_net", profile.Config.AllowNet). - Strs("dns_servers", dnsServers). + Strs("dns_servers_v4", dnsV4). + Strs("dns_servers_v6", dnsV6). Msg("starting bwrap network-filtered sandbox") var tmpFiles []string @@ -240,7 +241,10 @@ func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd } defer infoR.Close() //nolint:errcheck - bwrapArgs = appendBwrapNetArgs(bwrapArgs, profile.Config.AllowNet, dnsServers, cmd, args) + // IPv6 nameservers are dropped: the sandbox is IPv4-only (slirp4netns NAT, + // iptables-only rules). Feeding v6 addrs to iptables -d produces + // "host/network not found" errors. See issue #8. + bwrapArgs = appendBwrapNetArgs(bwrapArgs, profile.Config.AllowNet, dnsV4, cmd, args) bwrapCmd := exec.Command("bwrap", bwrapArgs...) bwrapCmd.ExtraFiles = []*os.File{infoW} // fd 3 in child diff --git a/services/platform_linux_test.go b/services/platform_linux_test.go index d20dfd7..11baeee 100644 --- a/services/platform_linux_test.go +++ b/services/platform_linux_test.go @@ -380,6 +380,39 @@ 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 + 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) if !strings.Contains(script, "nameserver 10.0.2.3") { @@ -512,6 +545,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" From 4471c47c0371e2d945d17ae44e85a1cdbdeadc82 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Thu, 21 May 2026 20:58:56 +0200 Subject: [PATCH 2/3] feat: add IPv6 sandbox support via slirp4netns --enable-ipv6 + ip6tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the network-filtered sandbox to also filter IPv6 egress on hosts that support it. The v4 path is unchanged; v6 is enabled when both conditions hold: - kernel v6 is enabled (/proc/sys/net/ipv6/conf/all/disable_ipv6 == 0) - ip6tables is on PATH Either missing → sandbox stays v4-only (status quo). A partial v6 filter would leave the sandbox with v6 connectivity and no egress rules, which is worse than no v6 at all. Changes: - slirp4netns invoked with --enable-ipv6 when supported (both the unshare-orchestration shell path and the bwrap+exec.Command path) - resolv.conf inside sandbox gets `nameserver fd00::3` (slirp4netns v6 forwarder) alongside 10.0.2.3 when v6 is on - parallel ip6tables ruleset mirroring iptables: loopback, DNS port 53, allowed v6 nameservers, AAAA-resolved AllowNet hosts (via getent ahostsv6), final REJECT with icmp6-adm-prohibited - doctor reports ip6tables and surfaces the chosen v6/v4-only mode - sandbox script prelude (tap0 wait, resolv.conf bind) extracted to shared helpers to cut duplication between the unshare and bwrap paths Manually verified on Fedora 42 (host has v4+v6 nameservers in resolv.conf, which was the issue #8 repro shape): startup is silent, ip6tables OUTPUT chain ends with REJECT, iptables OUTPUT chain ends with REJECT. Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/doctor.go | 25 +++++ services/platform_linux.go | 153 ++++++++++++++++++++------ services/platform_linux_bwrap.go | 63 ++++++----- services/platform_linux_bwrap_test.go | 84 ++++++++++++-- services/platform_linux_test.go | 84 ++++++++++++-- 5 files changed, 325 insertions(+), 84 deletions(-) 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/services/platform_linux.go b/services/platform_linux.go index 631adc0..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) @@ -329,17 +357,19 @@ func splitDNSByFamily(servers []string) (v4, v6 []string) { // 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 { dnsV4, dnsV6 := splitDNSByFamily(getSystemDNS()) + ipv6Enabled := ipv6SandboxSupported() + if !ipv6Enabled { + dnsV6 = nil + } helpers.Log.Info(). Strs("allow_net", profile.Config.AllowNet). Strs("dns_servers_v4", dnsV4). Strs("dns_servers_v6", dnsV6). + Bool("ipv6", ipv6Enabled). Msg("starting network-filtered sandbox") - // IPv6 nameservers are dropped: the sandbox is IPv4-only (slirp4netns NAT, - // iptables-only rules). Feeding v6 addrs to iptables -d produces - // "host/network not found" errors. See issue #8. - innerScript := buildNetFilterScript(profile.Config.AllowNet, dnsV4, 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) } @@ -349,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 @@ -374,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. @@ -387,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 fa72037..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: @@ -215,10 +228,18 @@ func writeTmpFile(pattern, content string) (string, error) { // CAP_SYS_ADMIN in the initial user namespace. func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd string, args []string, stdout, stderr io.Writer) error { 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_v4", dnsV4). Strs("dns_servers_v6", dnsV6). + Bool("ipv6", ipv6Enabled). Msg("starting bwrap network-filtered sandbox") var tmpFiles []string @@ -241,10 +262,7 @@ func (p *LinuxPlatform) runWithBwrapNetFilter(profile domain.SandboxProfile, cmd } defer infoR.Close() //nolint:errcheck - // IPv6 nameservers are dropped: the sandbox is IPv4-only (slirp4netns NAT, - // iptables-only rules). Feeding v6 addrs to iptables -d produces - // "host/network not found" errors. See issue #8. - bwrapArgs = appendBwrapNetArgs(bwrapArgs, profile.Config.AllowNet, dnsV4, 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 @@ -286,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 { @@ -341,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") @@ -351,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 } @@ -399,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 11baeee..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") { @@ -391,6 +393,7 @@ func TestBuildNetFilterScript(t *testing.T) { script := buildNetFilterScript( nil, []string{"192.168.178.1"}, // post-splitDNSByFamily list + nil, false, profile, "echo", nil, ) // Sanity: v4 rule present. @@ -414,21 +417,21 @@ func TestBuildNetFilterScript(t *testing.T) { }) 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 { @@ -440,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) { @@ -486,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") @@ -494,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") } @@ -504,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") @@ -512,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") } @@ -801,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 /") } @@ -930,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") } From bc9a9b3b7d1c3ae922b11d4bca39ac84e80c4e1a Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Thu, 21 May 2026 21:04:02 +0200 Subject: [PATCH 3/3] docs: note IPv6 egress filtering via ip6tables Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/AI/README.md | 3 ++- docs/user/README.md | 10 +++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) 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/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**: