diff --git a/docs/user/README.md b/docs/user/README.md index 3ac9c48..79c836a 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -166,11 +166,14 @@ deny_read: - ".env.*" - "secrets/" - "credentials/" - - ".ssh/" + - "~/.ssh/" - "*.pem" - "*.key" - - ".aws/" - - ".gcloud/" + - "~/.aws/" + - "~/.gcloud/" + - "~/.kube/config" + - "~/.npmrc" + - "~/.pypirc" deny_exec: - "curl" - "wget" @@ -178,8 +181,7 @@ deny_exec: - "ssh" - "scp" - "kubectl delete" - - "kubectl create" - - "docker rm" + - "kubectl exec" allow_net: - "api.anthropic.com" - "api.openai.com" @@ -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 diff --git a/services/config_service.go b/services/config_service.go index af9fad9..5a7ccad 100644 --- a/services/config_service.go +++ b/services/config_service.go @@ -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", }, @@ -145,6 +145,8 @@ func (s *ConfigService) InitDefaultConfig() *domain.Config { "scp", "rsync", "ftp", + "kubectl delete", + "kubectl exec", }, AllowNet: []string{ "api.anthropic.com", diff --git a/services/config_service_test.go b/services/config_service_test.go index ecb289d..dc460dd 100644 --- a/services/config_service_test.go +++ b/services/config_service_test.go @@ -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) + } + } +} diff --git a/services/platform.go b/services/platform.go index b3b8b9c..85f2ec3 100644 --- a/services/platform.go +++ b/services/platform.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/AxeForging/aigate/domain" ) @@ -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) diff --git a/services/platform_darwin.go b/services/platform_darwin.go index f9ab9bc..e8ed647 100644 --- a/services/platform_darwin.go +++ b/services/platform_darwin.go @@ -5,6 +5,8 @@ package services import ( "fmt" "os" + "os/exec" + "path/filepath" "strings" "github.com/AxeForging/aigate/domain" @@ -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") diff --git a/services/platform_linux.go b/services/platform_linux.go index 033865f..1df8d91 100644 --- a/services/platform_linux.go +++ b/services/platform_linux.go @@ -9,6 +9,7 @@ import ( "net" "os" "os/exec" + "path/filepath" "strings" "github.com/AxeForging/aigate/domain" @@ -193,8 +194,19 @@ func (p *LinuxPlatform) runUnshare(profile domain.SandboxProfile, cmd string, ar "--", } - shellCmd := buildPolicyFile(profile) + buildMountOverrides(profile) + shellEscape(cmd, args) - fullArgs := append(unshareArgs, "sh", "-c", shellCmd) + var sb strings.Builder + // Ensure inherited mounts are private so bind mounts work in all environments. + // Modern unshare --mount does this (util-linux 2.27+), but be explicit for safety. + sb.WriteString("mount --make-rprivate / 2>/dev/null || true\n") + sb.WriteString(buildPolicyFile(profile)) + sb.WriteString(buildConfigDirOverride()) + sb.WriteString(buildMountOverrides(profile)) + sb.WriteString(buildExecDenyOverrides(profile)) + sb.WriteString("exec ") + sb.WriteString(shellEscape(cmd, args)) + sb.WriteString("\n") + + fullArgs := append(unshareArgs, "sh", "-c", sb.String()) return p.exec.RunPassthrough("unshare", fullArgs...) } @@ -348,6 +360,9 @@ func buildOrchestrationScript(innerScript string) string { 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". @@ -391,7 +406,9 @@ func buildNetFilterScript(allowNetHosts, dnsServers []string, profile domain.San // Write policy file + mount overrides (deny_read markers point here) sb.WriteString(buildPolicyFile(profile)) + sb.WriteString(buildConfigDirOverride()) sb.WriteString(buildMountOverrides(profile)) + sb.WriteString(buildExecDenyOverrides(profile)) // Execute the target command sb.WriteString("exec ") @@ -414,7 +431,7 @@ func buildPolicyFile(profile domain.SandboxProfile) string { } if len(profile.Config.DenyExec) > 0 { sb.WriteString(fmt.Sprintf("printf 'deny_exec: %s\\n'\n", strings.Join(profile.Config.DenyExec, ", "))) - sb.WriteString("printf 'These commands are blocked before the sandbox starts.\\n\\n'\n") + sb.WriteString("printf 'These commands are blocked both before and inside the sandbox.\\n\\n'\n") } if len(profile.Config.AllowNet) > 0 { sb.WriteString(fmt.Sprintf("printf 'allow_net: %s\\n'\n", strings.Join(profile.Config.AllowNet, ", "))) @@ -429,40 +446,135 @@ func buildPolicyFile(profile domain.SandboxProfile) string { // agents understand why the content is unavailable. Directories get a tmpfs // with a .aigate-denied marker file. Both point to /tmp/.aigate-policy for // the full restriction list. +// +// Each mount is independent — a failure to mount one path does not prevent +// mounting others or executing subsequent sandbox setup. func buildMountOverrides(profile domain.SandboxProfile) string { const denyMsg = "[aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions." const dirMsg = "[aigate] access denied: this directory is protected by sandbox policy. Run 'cat /tmp/.aigate-policy' to see all active restrictions." - var mountCmds []string - hasFileDeny := false + type mountEntry struct { + isDir bool + path string + } + var entries []mountEntry for _, pattern := range profile.Config.DenyRead { paths, _ := resolvePatterns([]string{pattern}, profile.WorkDir) for _, path := range paths { if info, err := os.Stat(path); err == nil { - if info.IsDir() { - mountCmds = append(mountCmds, fmt.Sprintf( - "mount -t tmpfs -o size=4k tmpfs %s && printf '%s\\n' > %s/.aigate-denied && mount -o remount,ro %s", - path, dirMsg, path, path)) - } else { - hasFileDeny = true - mountCmds = append(mountCmds, fmt.Sprintf("mount --bind /tmp/.aigate-denied %s", path)) - } + entries = append(entries, mountEntry{isDir: info.IsDir(), path: path}) } } } + if len(entries) == 0 { + return "" + } + var sb strings.Builder - if hasFileDeny { - sb.WriteString(fmt.Sprintf("printf '%s\\n' > /tmp/.aigate-denied && ", denyMsg)) + + // Create the deny marker file for file-level overrides (standalone command). + hasFile := false + for _, e := range entries { + if !e.isDir { + hasFile = true + break + } + } + if hasFile { + sb.WriteString(fmt.Sprintf("printf '%s\\n' > /tmp/.aigate-denied\n", denyMsg)) } - if len(mountCmds) > 0 { - sb.WriteString(strings.Join(mountCmds, " && ")) - sb.WriteString(" && ") + + // Each mount is independent — failures don't cascade. + for _, e := range entries { + if e.isDir { + sb.WriteString(fmt.Sprintf( + "{ mount -t tmpfs -o size=4k tmpfs \"%s\" && printf '%s\\n' > \"%s/.aigate-denied\" && mount -o remount,ro \"%s\"; } 2>/dev/null || true\n", + e.path, dirMsg, e.path, e.path)) + } else { + sb.WriteString(fmt.Sprintf("mount --bind /tmp/.aigate-denied \"%s\" 2>/dev/null || true\n", e.path)) + } } + return sb.String() } +// buildExecDenyOverrides generates shell commands to kernel-enforce deny_exec +// rules inside the sandbox using mount --bind overlays. +// +// Full command blocks (e.g. "curl"): creates a deny script and bind-mounts it +// over every instance of the binary found in $PATH directories. +// +// Subcommand blocks (e.g. "kubectl delete"): creates a wrapper script with a +// case-statement that checks arguments, copies the original binary aside, and +// bind-mounts the wrapper over the original. +func buildExecDenyOverrides(profile domain.SandboxProfile) string { + if len(profile.Config.DenyExec) == 0 { + return "" + } + + var fullBlocks []string + subBlocks := make(map[string][]string) // base command -> list of denied subcommands + + for _, entry := range profile.Config.DenyExec { + parts := strings.SplitN(entry, " ", 2) + if len(parts) == 2 { + subBlocks[parts[0]] = append(subBlocks[parts[0]], parts[1]) + } else { + fullBlocks = append(fullBlocks, entry) + } + } + + var sb strings.Builder + + // Create the deny script for full command blocks (standalone command). + if len(fullBlocks) > 0 { + sb.WriteString("printf '#!/bin/sh\\necho \"[aigate] blocked: this command is denied by sandbox policy\" >&2\\nexit 126\\n' > /tmp/.aigate-deny-exec && chmod +x /tmp/.aigate-deny-exec\n") + + // For each denied command, find all instances in PATH and overlay them. + // Each command is independent — a failure doesn't affect others. + for _, cmd := range fullBlocks { + sb.WriteString(fmt.Sprintf( + "for _d in $(echo \"$PATH\" | tr ':' ' '); do [ -x \"$_d/%s\" ] && mount --bind /tmp/.aigate-deny-exec \"$_d/%s\" 2>/dev/null; done\n", + cmd, cmd)) + } + } + + // Create wrapper scripts for subcommand blocks. + // Each wrapper is independent — a failure doesn't affect others. + for baseCmd, subs := range subBlocks { + // Build case statement arms for denied subcommands + var caseArms strings.Builder + for _, sub := range subs { + caseArms.WriteString(fmt.Sprintf("%s) echo \"[aigate] blocked: '%s %s' is denied by sandbox policy\" >&2; exit 126;; ", sub, baseCmd, sub)) + } + + wrapper := fmt.Sprintf("#!/bin/sh\nfor _a in \"$@\"; do case \"$_a\" in %s*) break;; esac; done\nexec /tmp/.aigate-orig-%s \"$@\"\n", + caseArms.String(), baseCmd) + + encoded := base64.StdEncoding.EncodeToString([]byte(wrapper)) + + // Find the original binary, copy it aside, then mount wrapper over it + sb.WriteString(fmt.Sprintf( + "_orig=$(command -v %s 2>/dev/null) && if [ -n \"$_orig\" ]; then cp \"$_orig\" /tmp/.aigate-orig-%s && printf '%%s' '%s' | base64 -d > /tmp/.aigate-wrap-%s && chmod +x /tmp/.aigate-wrap-%s && mount --bind /tmp/.aigate-wrap-%s \"$_orig\" 2>/dev/null; fi\n", + baseCmd, baseCmd, encoded, baseCmd, baseCmd, baseCmd)) + } + + return sb.String() +} + +// buildConfigDirOverride generates a shell command to hide ~/.aigate/ inside the +// sandbox by mounting a tmpfs over it. +func buildConfigDirOverride() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + configDir := filepath.Join(home, ".aigate") + return fmt.Sprintf("mount -t tmpfs -o size=4k tmpfs \"%s\" 2>/dev/null || true\n", configDir) +} + // shellEscape builds a shell command string from a command and its arguments. func shellEscape(cmd string, args []string) string { var sb strings.Builder diff --git a/services/platform_linux_test.go b/services/platform_linux_test.go index 67a2d61..b7ea060 100644 --- a/services/platform_linux_test.go +++ b/services/platform_linux_test.go @@ -571,3 +571,303 @@ func TestParseDNSFromFile(t *testing.T) { } }) } + +func TestResolvePatterns_TildeExpansion(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + // Create a file at ~/testfile + writeTestFile(t, tmpDir+"/testfile", "content") + + paths, err := resolvePatterns([]string{"~/testfile"}, "/irrelevant") + if err != nil { + t.Fatalf("resolvePatterns() error = %v", err) + } + if len(paths) != 1 { + t.Fatalf("resolvePatterns() len = %d, want 1", len(paths)) + } + if paths[0] != tmpDir+"/testfile" { + t.Errorf("resolvePatterns() = %q, want %q", paths[0], tmpDir+"/testfile") + } +} + +func TestResolvePatterns_TildeDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + // Create ~/.ssh/ directory + os.MkdirAll(tmpDir+"/.ssh", 0o755) + + paths, err := resolvePatterns([]string{"~/.ssh/"}, "/irrelevant") + if err != nil { + t.Fatalf("resolvePatterns() error = %v", err) + } + if len(paths) != 1 { + t.Fatalf("resolvePatterns() len = %d, want 1", len(paths)) + } + expected := tmpDir + "/.ssh" + if paths[0] != expected { + t.Errorf("resolvePatterns() = %q, want %q", paths[0], expected) + } +} + +func TestBuildExecDenyOverrides_Empty(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{DenyExec: nil}, + WorkDir: "/tmp", + } + result := buildExecDenyOverrides(profile) + if result != "" { + t.Errorf("buildExecDenyOverrides() with empty deny list should return empty, got %q", result) + } +} + +func TestBuildExecDenyOverrides_FullCommand(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyExec: []string{"curl", "wget"}, + }, + WorkDir: "/tmp", + } + result := buildExecDenyOverrides(profile) + + // Should create the deny script + if !strings.Contains(result, "/tmp/.aigate-deny-exec") { + t.Error("should create deny script at /tmp/.aigate-deny-exec") + } + // Should iterate PATH for each denied command + if !strings.Contains(result, "\"$_d/curl\"") { + t.Error("should search PATH for curl") + } + if !strings.Contains(result, "\"$_d/wget\"") { + t.Error("should search PATH for wget") + } + // Should mount --bind the deny script + if !strings.Contains(result, "mount --bind /tmp/.aigate-deny-exec") { + t.Error("should mount --bind deny script over binaries") + } +} + +func TestBuildExecDenyOverrides_Subcommand(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyExec: []string{"kubectl delete", "kubectl exec"}, + }, + WorkDir: "/tmp", + } + result := buildExecDenyOverrides(profile) + + // Should NOT create the full-deny script (no full blocks) + if strings.Contains(result, "/tmp/.aigate-deny-exec") { + t.Error("should not create full deny script for subcommand-only rules") + } + // Should create wrapper and original copy + if !strings.Contains(result, "/tmp/.aigate-orig-kubectl") { + t.Error("should copy original binary to /tmp/.aigate-orig-kubectl") + } + if !strings.Contains(result, "/tmp/.aigate-wrap-kubectl") { + t.Error("should create wrapper at /tmp/.aigate-wrap-kubectl") + } + // Should contain base64-encoded wrapper + if !strings.Contains(result, "base64 -d") { + t.Error("should decode wrapper from base64") + } + // Decode and verify the wrapper script content + // Extract the base64 portion + idx := strings.Index(result, "printf '%s' '") + if idx == -1 { + t.Fatal("could not find base64 content in script") + } + start := idx + len("printf '%s' '") + end := strings.Index(result[start:], "'") + encoded := result[start : start+end] + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("failed to decode wrapper: %v", err) + } + wrapper := string(decoded) + if !strings.Contains(wrapper, "delete)") { + t.Error("wrapper should contain case arm for 'delete'") + } + if !strings.Contains(wrapper, "exec)") { + t.Error("wrapper should contain case arm for 'exec'") + } + if !strings.Contains(wrapper, "/tmp/.aigate-orig-kubectl") { + t.Error("wrapper should exec the original binary from /tmp/.aigate-orig-kubectl") + } +} + +func TestBuildExecDenyOverrides_Mixed(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyExec: []string{"curl", "kubectl delete"}, + }, + WorkDir: "/tmp", + } + result := buildExecDenyOverrides(profile) + + // Should have both full-deny and subcommand wrappers + if !strings.Contains(result, "/tmp/.aigate-deny-exec") { + t.Error("should create deny script for full command block") + } + if !strings.Contains(result, "/tmp/.aigate-orig-kubectl") { + t.Error("should create wrapper for subcommand block") + } +} + +func TestBuildConfigDirOverride(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + result := buildConfigDirOverride() + expected := tmpDir + "/.aigate" + if !strings.Contains(result, "mount -t tmpfs") { + t.Error("should mount tmpfs") + } + if !strings.Contains(result, expected) { + t.Errorf("should mount over %s, got: %s", expected, result) + } +} + +func TestBuildNetFilterScript_MountMakeRprivate(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{}, + WorkDir: "/tmp", + } + script := buildNetFilterScript(nil, nil, profile, "echo", nil) + if !strings.Contains(script, "mount --make-rprivate /") { + t.Error("net filter script should start with mount --make-rprivate /") + } + // Verify rprivate comes BEFORE any bind mounts + rprivateIdx := strings.Index(script, "mount --make-rprivate /") + procIdx := strings.Index(script, "mount -t proc proc /proc") + if rprivateIdx > procIdx { + t.Error("mount --make-rprivate should come before mount -t proc") + } +} + +func TestBuildMountOverrides_IndependentMounts(t *testing.T) { + tmpDir := t.TempDir() + writeTestFile(t, tmpDir+"/secret1.txt", "secret") + writeTestFile(t, tmpDir+"/secret2.txt", "secret") + + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyRead: []string{"secret1.txt", "secret2.txt"}, + }, + WorkDir: tmpDir, + } + result := buildMountOverrides(profile) + + // Each mount should be on its own line (not joined with &&) + lines := strings.Split(strings.TrimSpace(result), "\n") + mountCount := 0 + for _, line := range lines { + if strings.Contains(line, "mount --bind") { + mountCount++ + // Each mount should have || true for resilience + if !strings.Contains(line, "|| true") { + t.Errorf("mount line should have || true: %s", line) + } + } + } + if mountCount != 2 { + t.Errorf("expected 2 independent mount commands, got %d", mountCount) + } + + // Should NOT contain && between mount commands (cascading failure risk) + if strings.Contains(result, "mount --bind /tmp/.aigate-denied") && + strings.Contains(result, "&& mount --bind") { + t.Error("mount commands should NOT be joined with && (cascading failure)") + } +} + +func TestBuildMountOverrides_QuotesPaths(t *testing.T) { + tmpDir := t.TempDir() + writeTestFile(t, tmpDir+"/secret.txt", "secret") + + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyRead: []string{"secret.txt"}, + }, + WorkDir: tmpDir, + } + result := buildMountOverrides(profile) + // Path should be quoted + expected := fmt.Sprintf("mount --bind /tmp/.aigate-denied \"%s/secret.txt\"", tmpDir) + if !strings.Contains(result, expected) { + t.Errorf("mount should quote path, got:\n%s", result) + } +} + +func TestBuildMountOverrides_DirMount(t *testing.T) { + tmpDir := t.TempDir() + os.MkdirAll(tmpDir+"/secrets", 0o755) + + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyRead: []string{"secrets/"}, + }, + WorkDir: tmpDir, + } + result := buildMountOverrides(profile) + + // Dir mount should use tmpfs and have || true + if !strings.Contains(result, "mount -t tmpfs") { + t.Error("dir mount should use tmpfs") + } + if !strings.Contains(result, "|| true") { + t.Error("dir mount should have || true for resilience") + } + // Should NOT create the file deny marker (no file mounts) + if strings.Contains(result, "/tmp/.aigate-denied\n") { + // This would be the file marker creation, which shouldn't exist + // when there are only directory mounts + } +} + +func TestRunUnshare_MountMakeRprivate(t *testing.T) { + mock := newMockExecutor() + p := &LinuxPlatform{exec: mock} + profile := domain.SandboxProfile{ + Config: domain.Config{}, + WorkDir: "/tmp", + } + _ = p.RunSandboxed(profile, "echo", []string{"hello"}) + + if mock.callCount() == 0 { + t.Fatal("expected executor to be called") + } + last := mock.lastCall() + // The shell command is the last argument + shellCmd := last.Args[len(last.Args)-1] + if !strings.Contains(shellCmd, "mount --make-rprivate /") { + t.Error("runUnshare should include mount --make-rprivate /") + } + // Verify rprivate comes first (before any other commands) + if !strings.HasPrefix(shellCmd, "mount --make-rprivate /") { + t.Error("mount --make-rprivate should be the first command in the script") + } + // Verify exec is used before the command + if !strings.Contains(shellCmd, "exec echo hello") { + t.Error("runUnshare should exec the target command") + } +} + +func TestBuildNetFilterScript_IncludesExecDeny(t *testing.T) { + profile := domain.SandboxProfile{ + Config: domain.Config{ + DenyExec: []string{"curl", "wget"}, + DenyRead: []string{"/nonexistent/path/for/test"}, + }, + WorkDir: "/tmp", + } + + script := buildNetFilterScript(nil, nil, profile, "echo", []string{"hello"}) + if !strings.Contains(script, "/tmp/.aigate-deny-exec") { + t.Error("net filter script should include exec deny overrides") + } + if !strings.Contains(script, ".aigate") { + t.Error("net filter script should include config dir override") + } +}