Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions actions/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package actions

import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)")
Expand Down
3 changes: 2 additions & 1 deletion docs/AI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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

| | Linux | macOS |
|---|---|---|
| **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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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**:
Expand Down
174 changes: 140 additions & 34 deletions services/platform_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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))
Expand Down
Loading
Loading