Skip to content

Commit d3aaa34

Browse files
authored
fix: enforce deny_read, deny_exec, and config protection inside sandbox (#5)
Real-world security testing revealed that none of the sandbox policies were actually enforced inside the namespace — deny_read, deny_exec, and config protection all failed silently while the target command ran normally. Root cause: buildMountOverrides() joined all mount commands with && — any single mount failure cascaded to skip ALL subsequent security setup, but the target command still executed (separated by ; from the last for-loop). Fixes: - Add mount --make-rprivate / at sandbox entry for bind mount compatibility - Rewrite buildMountOverrides() with independent mounts (no cascading &&) - Add buildExecDenyOverrides() for kernel-level deny_exec enforcement - Add tilde expansion (~/) in resolvePatterns() for home-dir paths - Protect ~/.aigate/ config dir: tmpfs on Linux, Seatbelt deny on macOS - Add Seatbelt (deny process-exec) rules for deny_exec on macOS - Quote all paths in mount commands for space safety - Update default config with ~/ prefixes and subcommand examples - Mark resource_limits as coming soon in docs
1 parent 16483ff commit d3aaa34

7 files changed

Lines changed: 551 additions & 33 deletions

File tree

docs/user/README.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,22 @@ deny_read:
166166
- ".env.*"
167167
- "secrets/"
168168
- "credentials/"
169-
- ".ssh/"
169+
- "~/.ssh/"
170170
- "*.pem"
171171
- "*.key"
172-
- ".aws/"
173-
- ".gcloud/"
172+
- "~/.aws/"
173+
- "~/.gcloud/"
174+
- "~/.kube/config"
175+
- "~/.npmrc"
176+
- "~/.pypirc"
174177
deny_exec:
175178
- "curl"
176179
- "wget"
177180
- "nc"
178181
- "ssh"
179182
- "scp"
180183
- "kubectl delete"
181-
- "kubectl create"
182-
- "docker rm"
184+
- "kubectl exec"
183185
allow_net:
184186
- "api.anthropic.com"
185187
- "api.openai.com"
@@ -248,11 +250,16 @@ Restricts outbound connections to domains listed in `allow_net`:
248250

249251
### Command blocking
250252

251-
`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.
253+
`deny_exec` rules are enforced at two layers for defense-in-depth:
252254

253-
### Resource limits
255+
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.
256+
2. **Kernel-level enforcement inside the sandbox**:
257+
- **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.
258+
- **macOS**: Full command blocks use Seatbelt `(deny process-exec)` rules enforced by Sandbox.kext. Subcommand blocks rely on the pre-sandbox check.
254259

255-
cgroups v2 enforce memory, CPU, and PID limits (Linux only).
260+
### Resource limits *(coming soon)*
261+
262+
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.
256263

257264
## Troubleshooting
258265

services/config_service.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,15 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config {
123123
".env.*",
124124
"secrets/",
125125
"credentials/",
126-
".ssh/",
126+
"~/.ssh/",
127127
"*.pem",
128128
"*.key",
129129
"*.p12",
130-
".aws/",
131-
".gcloud/",
132-
".kube/config",
133-
".npmrc",
134-
".pypirc",
130+
"~/.aws/",
131+
"~/.gcloud/",
132+
"~/.kube/config",
133+
"~/.npmrc",
134+
"~/.pypirc",
135135
"terraform.tfstate",
136136
"*.tfvars",
137137
},
@@ -145,6 +145,8 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config {
145145
"scp",
146146
"rsync",
147147
"ftp",
148+
"kubectl delete",
149+
"kubectl exec",
148150
},
149151
AllowNet: []string{
150152
"api.anthropic.com",

services/config_service_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,71 @@ func TestAppendUnique(t *testing.T) {
163163
t.Errorf("appendUnique len = %d, want 5", len(result))
164164
}
165165
}
166+
167+
func TestInitDefaultConfig_TildePrefixes(t *testing.T) {
168+
svc := NewConfigService()
169+
cfg := svc.InitDefaultConfig()
170+
171+
tildePatterns := []string{"~/.ssh/", "~/.aws/", "~/.gcloud/", "~/.kube/config", "~/.npmrc", "~/.pypirc"}
172+
for _, want := range tildePatterns {
173+
found := false
174+
for _, got := range cfg.DenyRead {
175+
if got == want {
176+
found = true
177+
break
178+
}
179+
}
180+
if !found {
181+
t.Errorf("DenyRead missing %q", want)
182+
}
183+
}
184+
185+
// These should NOT have tilde prefix (they are project-relative)
186+
projectPatterns := []string{".env", ".env.*", "secrets/", "credentials/"}
187+
for _, want := range projectPatterns {
188+
found := false
189+
for _, got := range cfg.DenyRead {
190+
if got == want {
191+
found = true
192+
break
193+
}
194+
}
195+
if !found {
196+
t.Errorf("DenyRead missing project-relative pattern %q", want)
197+
}
198+
}
199+
}
200+
201+
func TestInitDefaultConfig_SubcommandExamples(t *testing.T) {
202+
svc := NewConfigService()
203+
cfg := svc.InitDefaultConfig()
204+
205+
subcommandExamples := []string{"kubectl delete", "kubectl exec"}
206+
for _, want := range subcommandExamples {
207+
found := false
208+
for _, got := range cfg.DenyExec {
209+
if got == want {
210+
found = true
211+
break
212+
}
213+
}
214+
if !found {
215+
t.Errorf("DenyExec missing subcommand example %q", want)
216+
}
217+
}
218+
219+
// Also check that full command blocks are still present
220+
fullCommands := []string{"curl", "wget", "nc", "ssh", "scp"}
221+
for _, want := range fullCommands {
222+
found := false
223+
for _, got := range cfg.DenyExec {
224+
if got == want {
225+
found = true
226+
break
227+
}
228+
}
229+
if !found {
230+
t.Errorf("DenyExec missing full command %q", want)
231+
}
232+
}
233+
}

services/platform.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"strings"
89

910
"github.com/AxeForging/aigate/domain"
1011
)
@@ -57,11 +58,18 @@ func DetectPlatformWithExecutor(exec Executor) Platform {
5758
}
5859

5960
// resolvePatterns expands glob patterns relative to workDir into absolute paths.
61+
// Patterns starting with ~/ are expanded to the user's home directory.
6062
func resolvePatterns(patterns []string, workDir string) ([]string, error) {
6163
var resolved []string
6264
for _, pattern := range patterns {
6365
var absPattern string
64-
if filepath.IsAbs(pattern) {
66+
if strings.HasPrefix(pattern, "~/") {
67+
home, err := os.UserHomeDir()
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to resolve home directory for %q: %w", pattern, err)
70+
}
71+
absPattern = filepath.Join(home, pattern[2:])
72+
} else if filepath.IsAbs(pattern) {
6573
absPattern = pattern
6674
} else {
6775
absPattern = filepath.Join(workDir, pattern)

services/platform_darwin.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package services
55
import (
66
"fmt"
77
"os"
8+
"os/exec"
9+
"path/filepath"
810
"strings"
911

1012
"github.com/AxeForging/aigate/domain"
@@ -221,6 +223,25 @@ func generateSeatbeltProfile(profile domain.SandboxProfile) string {
221223
}
222224
}
223225

226+
// Deny read access to aigate config directory
227+
if home, err := os.UserHomeDir(); err == nil {
228+
configDir := filepath.Join(home, ".aigate")
229+
sb.WriteString(fmt.Sprintf("(deny file-read* (subpath %q))\n", configDir))
230+
}
231+
232+
// Deny execution of blocked commands
233+
for _, entry := range profile.Config.DenyExec {
234+
parts := strings.SplitN(entry, " ", 2)
235+
if len(parts) == 2 {
236+
// Subcommand blocks can't be enforced via Seatbelt; pre-sandbox check handles these
237+
continue
238+
}
239+
// Full command block: find all instances via PATH
240+
if path, err := exec.LookPath(entry); err == nil {
241+
sb.WriteString(fmt.Sprintf("(deny process-exec (literal %q))\n", path))
242+
}
243+
}
244+
224245
// Network restrictions
225246
if len(profile.Config.AllowNet) > 0 {
226247
sb.WriteString("(deny network-outbound)\n")

0 commit comments

Comments
 (0)