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
23 changes: 15 additions & 8 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,22 @@ deny_read:
- ".env.*"
- "secrets/"
- "credentials/"
- ".ssh/"
- "~/.ssh/"
- "*.pem"
- "*.key"
- ".aws/"
- ".gcloud/"
- "~/.aws/"
- "~/.gcloud/"
- "~/.kube/config"
- "~/.npmrc"
- "~/.pypirc"
deny_exec:
- "curl"
- "wget"
- "nc"
- "ssh"
- "scp"
- "kubectl delete"
- "kubectl create"
- "docker rm"
- "kubectl exec"
allow_net:
- "api.anthropic.com"
- "api.openai.com"
Expand Down Expand Up @@ -248,11 +250,16 @@ Restricts outbound connections to domains listed in `allow_net`:

### Command blocking

`deny_exec` rules are checked **before** entering the sandbox. If the command (or a subcommand like `kubectl delete`) is in the deny list, aigate refuses to launch it. This is an application-level check, not a kernel feature.
`deny_exec` rules are enforced at two layers for defense-in-depth:

### Resource limits
1. **Pre-sandbox check**: Before entering the sandbox, aigate checks the command (and subcommands like `kubectl delete`) against the deny list and refuses to launch blocked commands.
2. **Kernel-level enforcement inside the sandbox**:
- **Linux**: Full command blocks use `mount --bind` to overlay denied binaries with a deny script. Subcommand blocks use wrapper scripts that check arguments before forwarding to the original binary.
- **macOS**: Full command blocks use Seatbelt `(deny process-exec)` rules enforced by Sandbox.kext. Subcommand blocks rely on the pre-sandbox check.

cgroups v2 enforce memory, CPU, and PID limits (Linux only).
### Resource limits *(coming soon)*

Resource limits (`max_memory`, `max_cpu_percent`, `max_pids`) are defined in the config but **not yet enforced**. Enforcement via cgroups v2 controllers is planned for a future release.

## Troubleshooting

Expand Down
14 changes: 8 additions & 6 deletions services/config_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,15 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config {
".env.*",
"secrets/",
"credentials/",
".ssh/",
"~/.ssh/",
"*.pem",
"*.key",
"*.p12",
".aws/",
".gcloud/",
".kube/config",
".npmrc",
".pypirc",
"~/.aws/",
"~/.gcloud/",
"~/.kube/config",
"~/.npmrc",
"~/.pypirc",
"terraform.tfstate",
"*.tfvars",
},
Expand All @@ -145,6 +145,8 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config {
"scp",
"rsync",
"ftp",
"kubectl delete",
"kubectl exec",
},
AllowNet: []string{
"api.anthropic.com",
Expand Down
68 changes: 68 additions & 0 deletions services/config_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,71 @@ func TestAppendUnique(t *testing.T) {
t.Errorf("appendUnique len = %d, want 5", len(result))
}
}

func TestInitDefaultConfig_TildePrefixes(t *testing.T) {
svc := NewConfigService()
cfg := svc.InitDefaultConfig()

tildePatterns := []string{"~/.ssh/", "~/.aws/", "~/.gcloud/", "~/.kube/config", "~/.npmrc", "~/.pypirc"}
for _, want := range tildePatterns {
found := false
for _, got := range cfg.DenyRead {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("DenyRead missing %q", want)
}
}

// These should NOT have tilde prefix (they are project-relative)
projectPatterns := []string{".env", ".env.*", "secrets/", "credentials/"}
for _, want := range projectPatterns {
found := false
for _, got := range cfg.DenyRead {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("DenyRead missing project-relative pattern %q", want)
}
}
}

func TestInitDefaultConfig_SubcommandExamples(t *testing.T) {
svc := NewConfigService()
cfg := svc.InitDefaultConfig()

subcommandExamples := []string{"kubectl delete", "kubectl exec"}
for _, want := range subcommandExamples {
found := false
for _, got := range cfg.DenyExec {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("DenyExec missing subcommand example %q", want)
}
}

// Also check that full command blocks are still present
fullCommands := []string{"curl", "wget", "nc", "ssh", "scp"}
for _, want := range fullCommands {
found := false
for _, got := range cfg.DenyExec {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("DenyExec missing full command %q", want)
}
}
}
10 changes: 9 additions & 1 deletion services/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/AxeForging/aigate/domain"
)
Expand Down Expand Up @@ -57,11 +58,18 @@ func DetectPlatformWithExecutor(exec Executor) Platform {
}

// resolvePatterns expands glob patterns relative to workDir into absolute paths.
// Patterns starting with ~/ are expanded to the user's home directory.
func resolvePatterns(patterns []string, workDir string) ([]string, error) {
var resolved []string
for _, pattern := range patterns {
var absPattern string
if filepath.IsAbs(pattern) {
if strings.HasPrefix(pattern, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to resolve home directory for %q: %w", pattern, err)
}
absPattern = filepath.Join(home, pattern[2:])
} else if filepath.IsAbs(pattern) {
absPattern = pattern
} else {
absPattern = filepath.Join(workDir, pattern)
Expand Down
21 changes: 21 additions & 0 deletions services/platform_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package services
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/AxeForging/aigate/domain"
Expand Down Expand Up @@ -221,6 +223,25 @@ func generateSeatbeltProfile(profile domain.SandboxProfile) string {
}
}

// Deny read access to aigate config directory
if home, err := os.UserHomeDir(); err == nil {
configDir := filepath.Join(home, ".aigate")
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath %q))\n", configDir))
}

// Deny execution of blocked commands
for _, entry := range profile.Config.DenyExec {
parts := strings.SplitN(entry, " ", 2)
if len(parts) == 2 {
// Subcommand blocks can't be enforced via Seatbelt; pre-sandbox check handles these
continue
}
// Full command block: find all instances via PATH
if path, err := exec.LookPath(entry); err == nil {
sb.WriteString(fmt.Sprintf("(deny process-exec (literal %q))\n", path))
}
}

// Network restrictions
if len(profile.Config.AllowNet) > 0 {
sb.WriteString("(deny network-outbound)\n")
Expand Down
Loading