Skip to content
Merged
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
29 changes: 23 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
cache: true

- name: Download dependencies
Expand All @@ -24,6 +24,23 @@ jobs:
- name: Build binary
run: make build-local

vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest

- name: Run govulncheck
run: govulncheck ./...

lint:
runs-on: ubuntu-latest
steps:
Expand All @@ -32,11 +49,11 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
cache: true

- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: --timeout=5m
run: golangci-lint run --timeout=5m
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
curl -L https://github.com/AxeForging/aigate/releases/latest/download/aigate-linux-amd64.tar.gz | tar xz
sudo mv aigate-linux-amd64 /usr/local/bin/aigate

# Install sandbox dependencies (Linux)
sudo dnf install bubblewrap slirp4netns # Fedora / RHEL
# sudo apt install bubblewrap slirp4netns # Ubuntu / Debian

# Set up sandbox
sudo aigate setup # One-time: create OS group/user for ACLs
aigate init # Create default config
aigate doctor # Verify prerequisites

# Add restrictions
aigate deny read .env secrets/ *.pem
Expand All @@ -44,8 +49,8 @@ AI coding tools rely on application-level permission systems that can be bypasse
## Features

- **File isolation** - POSIX ACLs (Linux) / macOS ACLs deny read access to secrets
- **Process isolation** - Mount namespaces overmount sensitive directories (Linux)
- **Network isolation** - Network namespaces restrict egress to allowed domains (Linux)
- **Process isolation** - Bubblewrap (`bwrap`) + mount namespaces isolate the sandbox declaratively (Linux); Seatbelt on macOS
- **Network isolation** - `bwrap --unshare-net` + `slirp4netns` + `iptables` restrict egress to allowed domains (Linux)
- **Command blocking** - Deny execution of dangerous commands (curl, wget, ssh)
- **Output masking** - Redact secrets (API keys, tokens) from stdout/stderr before they reach the terminal
- **Resource limits** - cgroups v2 enforce memory, CPU, PID limits (Linux)
Expand All @@ -67,6 +72,7 @@ AI coding tools rely on application-level permission systems that can be bypasse
```sh
sudo aigate setup # Create OS group/user (one-time)
aigate init # Create default config
aigate doctor # Check prerequisites and isolation mode
aigate deny read .env secrets/ *.pem # Block file access
aigate deny exec curl wget ssh # Block commands
aigate deny net --except api.anthropic.com # Restrict network
Expand Down
162 changes: 162 additions & 0 deletions actions/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package actions

import (
"fmt"
"os/exec"
"runtime"
"strings"

"github.com/urfave/cli"
)

type DoctorAction struct{}

func NewDoctorAction() *DoctorAction {
return &DoctorAction{}
}

func (a *DoctorAction) Execute(c *cli.Context) error {
fmt.Printf("aigate doctor — runtime checks (%s/%s)\n\n", runtime.GOOS, runtime.GOARCH)

switch runtime.GOOS {
case "linux":
runLinuxChecks()
case "darwin":
runDarwinChecks()
default:
fmt.Println(" No platform-specific checks for this OS.")
}

return nil
}

// ── Linux ────────────────────────────────────────────────────────────────────

func runLinuxChecks() {
bwrapOK := printCheck("bwrap",
"sandbox isolation (mount / pid / user namespaces)",
"sudo dnf install bubblewrap OR sudo apt install bubblewrap")

slirpOK := printCheck("slirp4netns",
"network filtering — required for allow_net rules",
"sudo dnf install slirp4netns OR sudo apt install slirp4netns")

printCheck("setfacl",
"persistent ACLs — deny_read enforced on disk between sessions",
"sudo dnf install acl OR sudo apt install acl")

unshareOK := checkUserNamespaces()

fmt.Println()
printLinuxIsolationMode(bwrapOK, slirpOK, unshareOK)
}

// printCheck looks up tool by name, prints a status line, and returns true if found.
// installHint is printed on a second line when the tool is missing.
func printCheck(name, desc, installHint string) bool {
path, err := exec.LookPath(name)
if err != nil {
fmt.Printf(" WARN %-16s not found\n", name)
fmt.Printf(" %s\n", desc)
if installHint != "" {
fmt.Printf(" Install: %s\n", installHint)
}
return false
}

ver := toolVersion(name)
if ver != "" {
fmt.Printf(" ok %-16s %s (%s)\n", name, ver, path)
} else {
fmt.Printf(" ok %-16s %s\n", name, path)
}
fmt.Printf(" %s\n", desc)
return true
}

// toolVersion runs `name --version` and returns the first meaningful token.
func toolVersion(name string) string {
out, err := exec.Command(name, "--version").CombinedOutput() //nolint:gosec
if err != nil {
return ""
}
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
// Keep only the version token (e.g. "bubblewrap 0.10.0" → "v0.10.0")
parts := strings.Fields(line)
for _, p := range parts {
if len(p) > 0 && (p[0] >= '0' && p[0] <= '9') {
return "v" + p
}
}
return ""
}

// checkUserNamespaces verifies that unprivileged user namespaces are enabled.
func checkUserNamespaces() bool {
// Attempt a trivial unshare; if it fails the kernel has them disabled.
err := exec.Command("unshare", "--user", "--", "true").Run()
if err != nil {
fmt.Printf(" WARN %-16s disabled\n", "user namespaces")
fmt.Printf(" Required for all sandbox modes.\n")
fmt.Printf(" Enable: echo 1 | sudo tee /proc/sys/kernel/unprivileged_userns_clone\n")
return false
}
fmt.Printf(" ok %-16s enabled\n", "user namespaces")
fmt.Printf(" Required for all sandbox modes.\n")
return true
}

// printLinuxIsolationMode describes which sandbox path will be taken based on
// available tools, mirroring the dispatch logic in RunSandboxed.
func printLinuxIsolationMode(bwrap, slirp, unshare bool) {
fmt.Println("Isolation mode:")

switch {
case bwrap && slirp:
fmt.Println(" bwrap + slirp4netns (full isolation)")
fmt.Println()
fmt.Println(" deny_read bwrap bind mounts kernel-enforced, per-run")
fmt.Println(" deny_exec bwrap bind mounts kernel-enforced, per-run")
fmt.Println(" allow_net bwrap --unshare-net network namespace via bwrap")
fmt.Println(" slirp4netns + iptables egress filtered to allowed hosts")
fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent")
case bwrap && !slirp:
fmt.Println(" bwrap (no network filtering — slirp4netns missing)")
fmt.Println()
fmt.Println(" deny_read bwrap bind mounts kernel-enforced, per-run")
fmt.Println(" deny_exec bwrap bind mounts kernel-enforced, per-run")
fmt.Println(" allow_net INACTIVE install slirp4netns to enable")
fmt.Println(" config dir bwrap tmpfs overlay ~/.aigate hidden from agent")
case !bwrap && slirp && unshare:
fmt.Println(" unshare + slirp4netns (fallback — install bwrap for stronger isolation)")
fmt.Println()
fmt.Println(" deny_read mount namespace overrides shell-script-based")
fmt.Println(" deny_exec mount namespace overrides shell-script-based")
fmt.Println(" allow_net unshare --net + slirp4netns egress filtered to allowed hosts")
fmt.Println(" config dir tmpfs mount ~/.aigate hidden from agent")
case !bwrap && !slirp && unshare:
fmt.Println(" unshare (fallback — install bwrap for stronger isolation)")
fmt.Println()
fmt.Println(" deny_read mount namespace overrides shell-script-based")
fmt.Println(" deny_exec mount namespace overrides shell-script-based")
fmt.Println(" allow_net INACTIVE install slirp4netns to enable")
fmt.Println(" config dir tmpfs mount ~/.aigate hidden from agent")
default:
fmt.Println(" NONE — user namespaces are disabled; sandbox cannot run")
}
}

// ── macOS ────────────────────────────────────────────────────────────────────

func runDarwinChecks() {
printCheck("sandbox-exec",
"macOS Seatbelt sandbox (deny_read, deny_exec, allow_net)",
"Built into macOS — should always be present")

fmt.Println()
fmt.Println("Isolation mode: sandbox-exec (Seatbelt)")
fmt.Println()
fmt.Println(" deny_read Seatbelt file-read* deny rules kernel-enforced")
fmt.Println(" deny_exec Seatbelt process-exec deny kernel-enforced")
fmt.Println(" allow_net Seatbelt network-outbound rules kernel-enforced")
}
19 changes: 11 additions & 8 deletions docs/AI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ domain/ Pure data structures
types.go Rule, Config, SandboxProfile, ResourceLimits

services/ Core business logic
platform.go Platform interface + Executor interface + resolvePatterns
platform_linux.go Linux: setfacl, groupadd/useradd, unshare (namespaces)
platform_darwin.go macOS: chmod +a, dscl, sandbox-exec
config_service.go Config load/save/merge (global + project)
rule_service.go Rule CRUD (add/remove/list deny rules)
runner_service.go Sandboxed process launcher
platform.go Platform interface + Executor interface + resolvePatterns
platform_linux.go Linux: setfacl, groupadd/useradd, RunSandboxed dispatch
platform_linux_bwrap.go Linux bwrap path: buildBwrapArgs, runWithBwrap, runWithBwrapNetFilter
platform_darwin.go macOS: chmod +a, dscl, sandbox-exec
config_service.go Config load/save/merge (global + project)
rule_service.go Rule CRUD (add/remove/list deny rules)
runner_service.go Sandboxed process launcher

actions/ CLI command handlers
init.go Create group, user, default config
Expand All @@ -28,6 +29,7 @@ actions/ CLI command handlers
run.go Run command inside sandbox
status.go Show current sandbox state
reset.go Remove group, user, config
doctor.go Check prerequisites and active isolation mode

helpers/ Logging and error types
logger.go zerolog console logger
Expand All @@ -40,8 +42,9 @@ integration/ End-to-end CLI tests
## Key Design Decisions

- **Platform interface**: Linux and macOS use completely different OS mechanisms. The `Platform` interface abstracts this with `newPlatform()` factory via build tags.
- **Executor interface**: All `exec.Command` calls go through `Executor`, enabling unit tests without root.
- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod).
- **Executor interface**: All `exec.Command` calls go through `Executor`, enabling unit tests without root. Exception: `runWithBwrapNetFilter` uses `exec.Command` directly because it needs `cmd.Start()` + `ExtraFiles` for the info-fd pipe, which the Executor interface does not expose.
- **bwrap-first on Linux**: `RunSandboxed` prefers bwrap when available; falls back to `unshare`-based shell scripts. bwrap uses declarative bind mounts (no shell injection risk), resolves symlinks for bind destinations, and handles capabilities via `--uid 0 --cap-add` for the network path.
- **No CGO**: All platform operations use `exec.Command` to call system utilities (setfacl, groupadd, dscl, chmod, bwrap, slirp4netns).
- **Config merging**: Global config (`~/.aigate/config.yaml`) + project config (`.aigate.yaml`) merge with project extending global.

## Testing
Expand Down
66 changes: 59 additions & 7 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,28 @@ Unlike application-level restrictions that can be bypassed, aigate uses kernel-e

| | Linux | macOS |
|---|---|---|
| **Required** | `setfacl` (usually pre-installed) | None (uses built-in sandbox-exec) |
| **Recommended** | `bwrap` (Bubblewrap) | None (uses built-in sandbox-exec) |
| **For network filtering** | `slirp4netns` | None (uses built-in Seatbelt) |
| **For persistent ACLs** | `setfacl` (usually pre-installed) | None |

Install `slirp4netns` on Linux if you use `allow_net`:
### Install Bubblewrap (recommended, Linux)

`bwrap` provides stronger isolation than the fallback `unshare` path. When present, aigate uses it for all sandbox modes.

```sh
# Fedora / RHEL
sudo dnf install bubblewrap

# Ubuntu / Debian
sudo apt install bubblewrap

# Arch
sudo pacman -S bubblewrap
```

Without `bwrap`, aigate falls back to `unshare`-based namespaces (still functional, but shell-script-based overrides instead of declarative bind mounts).

### Install slirp4netns (required for `allow_net`, Linux)

```sh
# Fedora / RHEL
Expand All @@ -32,6 +50,14 @@ sudo pacman -S slirp4netns

If `slirp4netns` is not installed, aigate logs a warning and runs without network filtering.

### Verify your setup

```sh
aigate doctor
```

Shows which tools are available and the isolation mode that will be used.

## Install

### Linux/macOS (AMD64)
Expand Down Expand Up @@ -144,6 +170,24 @@ Show current sandbox configuration:
aigate status
```

### doctor

Check sandbox prerequisites and show which isolation mode will be active:

```sh
aigate doctor
```

Example output:
```
ok bwrap v0.10.0 — sandbox isolation (mount/pid/user namespaces)
ok slirp4netns v1.3.1 — network filtering (allow_net rules)
ok setfacl v2.3.2 — persistent ACLs
ok user namespaces enabled

Isolation mode: bwrap + slirp4netns (full isolation)
```

### reset

Remove everything (group, user, config):
Expand Down Expand Up @@ -295,7 +339,8 @@ Two layers working together for defense-in-depth:

Restricts outbound connections to domains listed in `allow_net`:

- **Linux**: User namespace + network namespace + `slirp4netns` for user-mode networking + `iptables` OUTPUT rules. Hostnames are resolved inside the namespace so iptables IPs match what the sandboxed process sees. Requires `slirp4netns` (falls back to unrestricted if not installed). No root needed.
- **Linux (bwrap path)**: bwrap creates a network namespace via `--unshare-net`. Go reads bwrap's `--info-fd` to get the child PID, then launches `slirp4netns --configure` from host-side to attach user-mode networking. Inside the sandbox, `iptables` OUTPUT rules resolve each `allow_net` hostname and restrict egress. No root needed.
- **Linux (unshare fallback)**: Two-layer `unshare` — outer creates user namespace, inner creates network namespace. `slirp4netns` runs inside the user namespace. Same `iptables` filtering.
- **macOS**: `sandbox-exec` Seatbelt profiles with `(deny network-outbound)` and per-host `(allow network-outbound (remote ip ...))` rules. Kernel-enforced via Sandbox.kext.

**Linux**:
Expand All @@ -308,9 +353,13 @@ Restricts outbound connections to domains listed in `allow_net`:

### Process isolation (Linux)

- **User namespace**: Maps calling user to UID 0 inside the namespace, giving capabilities for mount/net operations without real root
- **PID namespace**: Sandboxed process sees itself as PID 1, cannot see or signal host processes. `/proc` is remounted to match
- **Mount namespace**: Enables filesystem overrides without affecting the host
When `bwrap` is installed (recommended):

- **User namespace** (`--unshare-user`): Maps calling user to a root-equivalent UID inside the namespace. Required for mount/net operations without real root.
- **PID namespace** (`--unshare-pid`): Sandboxed process sees itself as PID 1, cannot see or signal host processes. `/proc` is remounted fresh.
- **Mount namespace**: bwrap declaratively applies deny_read bind mounts, config-dir hiding, and deny_exec stubs before exec — no shell-based overrides.

Without `bwrap`, aigate falls back to `unshare --user --map-root-user` + shell scripts for the same effects.

![Linux Process Isolation](../diagrams/linux-process.png)

Expand Down Expand Up @@ -342,7 +391,10 @@ Run `sudo aigate setup` to create the sandbox group and user, then `aigate init`
Install `slirp4netns` for network filtering on Linux (see [Prerequisites](#prerequisites)). Without it, `allow_net` rules are ignored and the sandboxed process has unrestricted network access.

### Allowed hosts still blocked
If hosts in `allow_net` are being rejected, DNS inside the sandbox may not have been ready in time. Check that `slirp4netns` is installed and working. Run with `AIGATE_LOG_LEVEL=debug` for detailed output.
If hosts in `allow_net` are being rejected, DNS inside the sandbox may not have been ready in time. Check that `slirp4netns` is installed and working. Run `aigate doctor` to verify your setup, or use `AIGATE_LOG_LEVEL=debug` for detailed output.

### bwrap not found
Install `bubblewrap` for stronger isolation (see [Prerequisites](#prerequisites)). Without it, aigate falls back to the `unshare`-based sandbox which is still functional but uses shell-script-based mount overrides.

## Exit Codes

Expand Down
Loading
Loading