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
26 changes: 3 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,29 +75,9 @@ aigate reset --force # Remove everything

## How It Works

```
Your files: .env secrets/ .ssh/ *.pem
|
aigate init
|
OS group: ai-agents
OS user: ai-runner
ACLs: deny read for ai-agents
|
aigate run -- claude
|
+--namespace--+
| mount: hide |
| net: restrict|
| pid: isolate |
+-------------+
|
AI agent runs with:
- Cannot read .env, secrets/
- Cannot run curl, wget
- Cannot reach unauthorized hosts
- Cannot see host processes
```
![Linux Process Isolation](docs/diagrams/linux-process.png)

See [docs/user/README.md](docs/user/README.md) for detailed architecture diagrams covering file isolation, network isolation (Linux & macOS), and process isolation.

## Configuration

Expand Down
17 changes: 13 additions & 4 deletions actions/init.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package actions

import (
"errors"
"fmt"

"github.com/AxeForging/aigate/helpers"
Expand Down Expand Up @@ -31,16 +32,24 @@ func (a *InitAction) Execute(c *cli.Context) error {

helpers.Log.Info().Str("platform", a.platform.Name()).Msg("Initializing aigate sandbox")

// Create group
// Create group — skip if already exists
helpers.Log.Info().Str("group", group).Msg("Creating sandbox group")
if err := a.platform.CreateGroup(group); err != nil {
return fmt.Errorf("failed to create group: %w", err)
if errors.Is(err, helpers.ErrAlreadyInit) {
helpers.Log.Info().Str("group", group).Msg("Sandbox group already exists, skipping")
} else {
return fmt.Errorf("failed to create group: %w", err)
}
}

// Create user
// Create user — skip if already exists
helpers.Log.Info().Str("user", user).Str("group", group).Msg("Creating sandbox user")
if err := a.platform.CreateUser(user, group); err != nil {
return fmt.Errorf("failed to create user: %w", err)
if errors.Is(err, helpers.ErrAlreadyInit) {
helpers.Log.Info().Str("user", user).Msg("Sandbox user already exists, skipping")
} else {
return fmt.Errorf("failed to create user: %w", err)
}
}

// Write default config
Expand Down
18 changes: 18 additions & 0 deletions actions/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package actions
import (
"fmt"
"os"
"strings"

"github.com/AxeForging/aigate/domain"
"github.com/AxeForging/aigate/helpers"
Expand Down Expand Up @@ -60,5 +61,22 @@ func (a *RunAction) Execute(c *cli.Context) error {
Int("allow_net", len(merged.AllowNet)).
Msg("Running sandboxed command")

printSandboxBanner(merged)

return a.runner.Run(profile, cmd, cmdArgs)
}

// printSandboxBanner prints active restrictions to stderr so AI agents
// (and users) can see exactly what is enforced inside the sandbox.
func printSandboxBanner(cfg *domain.Config) {
fmt.Fprintln(os.Stderr, "[aigate] sandbox active")
if len(cfg.DenyRead) > 0 {
fmt.Fprintf(os.Stderr, "[aigate] deny_read: %s\n", strings.Join(cfg.DenyRead, ", "))
}
if len(cfg.DenyExec) > 0 {
fmt.Fprintf(os.Stderr, "[aigate] deny_exec: %s\n", strings.Join(cfg.DenyExec, ", "))
}
if len(cfg.AllowNet) > 0 {
fmt.Fprintf(os.Stderr, "[aigate] allow_net: %s (all other outbound connections will be blocked)\n", strings.Join(cfg.AllowNet, ", "))
}
}
Binary file added docs/diagrams/file-isolation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions docs/diagrams/file-isolation.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
@startuml file-isolation
!theme plain
skinparam backgroundColor #FEFEFE
skinparam defaultFontName Inter
skinparam shadowing false
skinparam roundcorner 8
skinparam ArrowColor #444444
skinparam RectangleBorderColor #888888
skinparam PackageBorderColor #666666
skinparam NoteBackgroundColor #FFFDE7
skinparam NoteBorderColor #FBC02D

title **File Isolation -- Linux & macOS**\ndual-layer: ACLs (persistent) + namespace overrides (runtime)

rectangle "Layer 1: Persistent ACLs\n(applied at deny/allow time)" as acls #C8E6C9 {

rectangle "**Linux -- POSIX ACLs**\nsetfacl -m g:ai-agents:--- <path>" as linuxacl #A5D6A7

rectangle "**macOS -- Extended ACLs**\nchmod +a ""group:ai-agents deny read"" <path>" as macosacl #A5D6A7
}

rectangle "Layer 2: Runtime Overrides\n(applied inside sandbox)" as runtime #FFECB3 {

rectangle "**Directories**\nmount -t tmpfs -o ro,size=0 tmpfs <path>" as tmpfs #FFE082
rectangle "**Files**\nmount --bind /dev/null <path>" as devnull #FFE082
}

rectangle "**Protected Paths**" as paths #FFCDD2 {
rectangle ".env, .env.*, *.pem, *.key" as p1 #FFFFFF
rectangle ".ssh/, .aws/, .gcloud/" as p2 #FFFFFF
rectangle "secrets/, credentials/" as p3 #FFFFFF
}

paths -up-> acls : aigate deny read
paths -up-> runtime : aigate run

note right of linuxacl
Recursive with inheritance:
setfacl -R -m g:ai-agents:--- dir/
setfacl -R -m d:g:ai-agents:--- dir/
end note

note right of macosacl
Denies: read, readattr,
readextattr, readsecurity
Dirs add: list, search,
file_inherit, directory_inherit
end note

note right of tmpfs
Only on **Linux** (mount namespaces).
macOS uses Seatbelt file-read* deny
rules instead:
(deny file-read* (subpath "/secrets"))
(deny file-read* (literal ".env"))
end note

note bottom of paths
Both layers work together:
ACLs block at the OS level
(survives across sessions).
Mount overrides add defense-in-depth
at runtime (even root inside the
namespace sees empty files/dirs).
end note

@enduml
Binary file added docs/diagrams/linux-network.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 77 additions & 0 deletions docs/diagrams/linux-network.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
@startuml linux-network
!theme plain
skinparam backgroundColor #FEFEFE
skinparam defaultFontName Inter
skinparam shadowing false
skinparam roundcorner 8
skinparam ArrowColor #444444
skinparam RectangleBorderColor #888888
skinparam PackageBorderColor #666666
skinparam NoteBackgroundColor #FFFDE7
skinparam NoteBorderColor #FBC02D

title **Linux Network Isolation**\nslirp4netns + iptables (no root required)

cloud "Internet" as internet #E0E0E0

rectangle "User Namespace\n(outer unshare --user)" as userns #FFF3E0 {

rectangle "**slirp4netns**\nuser-mode networking\n(host network + user ns caps)" as slirp #B3E5FC

rectangle "Network Namespace\n(inner unshare --net)" as netns #FFCDD2 {

rectangle "**tap0** (10.0.2.100)" as tap0 #EF9A9A

rectangle "**iptables OUTPUT chain**" as iptables #EF9A9A {
rectangle "ACCEPT lo (loopback)" as r1 #FFFFFF
rectangle "ACCEPT UDP/TCP :53 (DNS)" as r2 #FFFFFF
rectangle "ACCEPT upstream DNS servers" as r3 #FFFFFF
rectangle "ACCEPT resolved AllowNet IPs" as r4 #C8E6C9
rectangle "REJECT everything else" as r5 #FFCDD2
}

rectangle "**Sandboxed Process**\nclaude / cursor / aider" as proc #CE93D8
}
}

proc -down-> iptables : outbound traffic
iptables -down-> tap0 : allowed
tap0 <-left-> slirp : tap attachment\nvia setns()
slirp <-up-> internet : forwarded

note right of slirp
Runs **inside** user namespace
(has CAP_SYS_ADMIN) but in the
**host** network namespace.

Creates tap0 in sandbox net ns,
forwards packets to real network.

Built-in DNS forwarder: 10.0.2.3
end note

note right of r4
Hosts resolved **inside** namespace
via getent ahostsv4 (same DNS the
sandboxed process will use).
Each host retries 3x to handle
DNS startup delay.
end note

note left of tap0
**resolv.conf** bind-mounted
to point at 10.0.2.3
(slirp4netns DNS forwarder)
end note

note right of proc
**Dispatch logic:**
AllowNet + slirp4netns found
-> runWithNetFilter()
AllowNet + no slirp4netns
-> warn, run unrestricted
No AllowNet
-> run unrestricted
end note

@enduml
Binary file added docs/diagrams/linux-process.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions docs/diagrams/linux-process.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
@startuml linux-process
!theme plain
skinparam backgroundColor #FEFEFE
skinparam defaultFontName Inter
skinparam shadowing false
skinparam roundcorner 8
skinparam ArrowColor #444444
skinparam RectangleBorderColor #888888
skinparam PackageBorderColor #666666
skinparam NoteBackgroundColor #FFFDE7
skinparam NoteBorderColor #FBC02D

title **Linux Process & Filesystem Isolation**\nnamespaces + mount overrides (no root required)

actor "User" as user

rectangle "**aigate run -- <cmd>**" as aigate #E3F2FD

rectangle "User Namespace\n(unshare --user --map-root-user)" as userns #FFF3E0 {

rectangle "Mount Namespace\n(unshare --mount)" as mntns #FFECB3 {

rectangle "**Mount Overrides**\n(deny_read enforcement)" as mounts #FFE082

rectangle "**/proc remount**\n(mount -t proc proc /proc)" as procmnt #FFE082

rectangle "**resolv.conf**\nbind-mount to 10.0.2.3\n(only with AllowNet)" as resolv #FFE082
}

rectangle "PID Namespace\n(unshare --pid --fork)" as pidns #E1BEE7 {
rectangle "**Sandboxed Process**\n<cmd> <args>" as proc #CE93D8
}
}

user --> aigate
aigate --> userns

note right of mounts
**deny_read paths:**
Directories -> tmpfs (ro, size=0)
Files -> bind /dev/null

Examples:
mount -t tmpfs tmpfs ~/.ssh/
mount --bind /dev/null .env
end note

note right of proc
Process sees itself as PID 1.
Cannot see or signal any host
processes. /proc remounted to
match the new PID namespace.
end note

note left of userns
Maps calling user to UID 0
inside the namespace. Gives
CAP_SYS_ADMIN for mount/net
operations without real root.
end note

note bottom of aigate
**deny_exec enforcement:**
Checked BEFORE entering the
sandbox. If the command (or
subcommand like "kubectl delete")
is in the deny list, aigate
refuses to run it.
end note

@enduml
Binary file added docs/diagrams/macos-network.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions docs/diagrams/macos-network.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
@startuml macos-network
!theme plain
skinparam backgroundColor #FEFEFE
skinparam defaultFontName Inter
skinparam shadowing false
skinparam roundcorner 8
skinparam ArrowColor #444444
skinparam RectangleBorderColor #888888
skinparam PackageBorderColor #666666
skinparam NoteBackgroundColor #FFFDE7
skinparam NoteBorderColor #FBC02D

title **macOS Network Isolation**\nsandbox-exec Seatbelt profiles (kernel-enforced)

cloud "Internet" as internet #E0E0E0

rectangle "sandbox-exec (Sandbox.kext)" as sandbox #E3F2FD {

rectangle "**Seatbelt Profile (.sb)**" as profile #BBDEFB {
rectangle "(deny network-outbound)" as deny #FFCDD2
rectangle "(allow network-outbound (local ip))" as lo #FFFFFF
rectangle "(allow network-outbound\n (remote ip ""api.anthropic.com""))" as allow1 #C8E6C9
rectangle "(allow network-outbound\n (remote ip ""api.github.com""))" as allow2 #C8E6C9
}

rectangle "**Sandboxed Process**\nclaude / cursor / aider" as proc #CE93D8
}

proc -up-> profile : outbound traffic\nchecked by kernel
allow1 -up-> internet : allowed
allow2 -up-> internet : allowed
deny -right[hidden]-> lo

note right of proc
macOS **Sandbox.kext** enforces
the Seatbelt profile at the
kernel level.

No user-mode networking needed.
The kernel intercepts syscalls
and denies disallowed connections.
end note

note right of profile
Profile generated dynamically
by aigate from the allow_net
config. Written to a temp .sb
file and passed to sandbox-exec.

AllowNet hostnames are passed
directly (Seatbelt handles
DNS resolution natively).
end note

@enduml
Loading