diff --git a/internal/sandbox/config.go b/internal/sandbox/config.go index ef37d56..1122454 100644 --- a/internal/sandbox/config.go +++ b/internal/sandbox/config.go @@ -2,6 +2,7 @@ package sandbox import ( "fmt" + "io" "os" "path/filepath" "regexp" @@ -12,7 +13,7 @@ import ( "gopkg.in/yaml.v3" ) -// Config represents the global sandbox configuration +// Config represents the sandbox configuration type Config struct { General GeneralConfig `yaml:"general"` Filesystem FilesystemConfig `yaml:"filesystem"` @@ -21,13 +22,6 @@ type Config struct { Advanced AdvancedConfig `yaml:"advanced"` } -// LayeredConfig represents a single layer (system/backend/role/task) -type LayeredConfig struct { - Filesystem FilesystemConfig `yaml:"filesystem"` - Network NetworkConfig `yaml:"network"` - Env []string `yaml:"env"` -} - // GeneralConfig contains general sandbox settings type GeneralConfig struct { BestEffort bool `yaml:"best_effort"` @@ -56,148 +50,11 @@ type AdvancedConfig struct { AddExec bool `yaml:"add_exec"` } -// DefaultConfig returns the default locked-down configuration -func DefaultConfig() *Config { - return &Config{ - General: GeneralConfig{ - BestEffort: false, // Fail-closed: refuse to run if sandboxing unavailable - LogLevel: "error", - }, - Filesystem: FilesystemConfig{ - RO: []string{ - "/etc/passwd", // UID→homedir lookup (getpwuid) - "/dev/null", // Null device - "/proc/meminfo", // Memory info - "/proc/self/cgroup", // Cgroup info - "/proc/self/maps", // Process memory maps - "/proc/version", // Kernel version - }, - ROX: []string{ - "/usr", - "/lib", - "/lib64", - "/bin", - "/sbin", - }, - RW: []string{ - "{CWD}", - "{TMPDIR}", - "{OLLIE_TMP_PATH}", - "{HOME}/.claude", - "{HOME}/.kiro", - "{HOME}/.claude.json", - }, - RWX: []string{ - "{OLLIE_CFG_PATH}", - "{OLLIE}", - }, - }, - Network: NetworkConfig{ - Enabled: false, - Unrestricted: true, // Unrestricted network (fine-grained restrictions require Landlock v5+) - BindTCP: []string{}, - ConnectTCP: []string{"443"}, - }, - Env: []string{ - "HOME", - "USER", - "PATH", - "LANG", - "TERM", - "OLLIE_SESSION_ID", - "OLLIE", - "OLLIE_CFG_PATH", - "OLLIE_DATA_PATH", - "OLLIE_TMP_PATH", - "OLLIE_MEMORY_PATH", - }, - Advanced: AdvancedConfig{ - LDD: false, - AddExec: true, - }, - } -} +// defaultSandbox is the sandbox config used when none is specified +const defaultSandbox = "default" -// ConfigPath returns the path to the sandbox config file -func ConfigPath() string { - return filepath.Join(paths.CfgDir(), "sandbox.yaml") -} - -// Load loads the config from disk, creating defaults if it doesn't exist -func Load() (*Config, error) { - path := ConfigPath() - - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - cfg := DefaultConfig() - if saveErr := Save(cfg); saveErr != nil { - return cfg, saveErr - } - return cfg, nil - } - if err != nil { - return nil, err - } - - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, err - } - - return &cfg, nil -} - -// Save saves the config to disk -func Save(cfg *Config) error { - path := ConfigPath() - - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - - data, err := yaml.Marshal(cfg) - if err != nil { - return err - } - - header := `# Ollie Sandbox Configuration -# Controls landrun sandboxing for execute_code -# Documentation: https://github.com/landlock-lsm/landrun -# -# Path templates: -# {CWD} - Session working directory -# {HOME} - User home directory -# {TMPDIR} - Temporary directory (/tmp or $TMPDIR) -# -# Changes apply to NEW sessions only. - -` - data = append([]byte(header), data...) - - return os.WriteFile(path, data, 0644) -} - -// DefaultSandbox is the sandbox config used when none is specified -const DefaultSandbox = "default" - -// validateName checks if a name is safe for use in file paths -func validateName(name string) error { - if name == "" { - return fmt.Errorf("name cannot be empty") - } - if len(name) > 64 { - return fmt.Errorf("name too long (max 64 characters)") - } - for _, r := range name { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { - return fmt.Errorf("name contains invalid character: %c (only alphanumeric, hyphen, underscore allowed)", r) - } - } - return nil -} - -// ExpandPath replaces template variables with actual values -func ExpandPath(pattern, cwd string) string { +// expandPath replaces template variables with actual values +func expandPath(pattern, cwd string) string { s := pattern s = strings.ReplaceAll(s, "{CWD}", cwd) @@ -256,32 +113,10 @@ func ExpandPath(pattern, cwd string) string { return s } -// LoadMerged loads the base config merged with a named sandbox layer. -// If the named layer doesn't exist, returns the base config alone. -func LoadMerged(name string) (*Config, error) { - baseCfg, err := Load() - if err != nil { - return nil, err - } - if name == "" { - name = "default" - } - baseLayer := LayeredConfig{ - Filesystem: baseCfg.Filesystem, - Network: baseCfg.Network, - Env: baseCfg.Env, - } - layers := []LayeredConfig{baseLayer} - if sbxLayer, err := LoadSandbox(name); err == nil { - layers = append(layers, sbxLayer) - } - return Merge(baseCfg.General, baseCfg.Advanced, layers...), nil -} - -// CheckPath checks if the given absolute path is allowed by the sandbox config. +// checkPath checks if the given absolute path is allowed by the sandbox config. // If write is true, the path must fall under an RW or RWX entry. // If write is false, any entry (RO, ROX, RW, RWX) grants access. -func CheckPath(cfg *Config, path string, write bool, cwd string) error { +func checkPath(cfg *Config, path string, write bool, cwd string) error { cleaned := filepath.Clean(path) if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { cleaned = resolved @@ -294,17 +129,17 @@ func CheckPath(cfg *Config, path string, write bool, cwd string) error { var allowed []string for _, p := range cfg.Filesystem.RW { - allowed = append(allowed, ExpandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd)) } for _, p := range cfg.Filesystem.RWX { - allowed = append(allowed, ExpandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd)) } if !write { for _, p := range cfg.Filesystem.RO { - allowed = append(allowed, ExpandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd)) } for _, p := range cfg.Filesystem.ROX { - allowed = append(allowed, ExpandPath(p, cwd)) + allowed = append(allowed, expandPath(p, cwd)) } } @@ -324,144 +159,15 @@ func pathUnder(path, root string) bool { return path == root || strings.HasPrefix(path, root+string(filepath.Separator)) } -// SystemDefaults returns the system-level defaults (restrictive baseline) -func SystemDefaults() LayeredConfig { - return LayeredConfig{ - Filesystem: FilesystemConfig{ - RO: []string{ - "/etc/passwd", - "/dev/null", - "/proc/meminfo", - "/proc/self/cgroup", - "/proc/self/maps", - "/proc/version", - }, - ROX: []string{ - "/usr", - "/lib", - "/lib64", - "/bin", - "/sbin", - }, - RW: []string{ - "{TMPDIR}", - }, - }, - Network: NetworkConfig{ - Enabled: false, - Unrestricted: false, - }, - Env: []string{ - "HOME", - "USER", - "PATH", - "LANG", - "TERM", - "OLLIE_SESSION_ID", - "OLLIE", - "OLLIE_CFG_PATH", - "OLLIE_DATA_PATH", - }, - } -} - -// LoadSandbox loads a named sandbox layer config from $OLLIE_CFG_PATH/sandbox/.yaml -func LoadSandbox(name string) (LayeredConfig, error) { - if err := validateName(name); err != nil { - return LayeredConfig{}, fmt.Errorf("invalid sandbox name: %w", err) - } - - path := filepath.Join(paths.CfgDir(), "sandbox", name+".yaml") - - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return LayeredConfig{}, fmt.Errorf("sandbox %q not found (looked in %s)", name, path) - } +// LoadSandbox parses a sandbox config from r. +func LoadSandbox(r io.Reader) (*Config, error) { + data, err := io.ReadAll(r) if err != nil { - return LayeredConfig{}, err + return nil, err } - - var cfg LayeredConfig + var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { - return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, err) + return nil, err } - - return cfg, nil -} - -// Merge combines multiple layers into a final Config (most permissive wins) -func Merge(general GeneralConfig, advanced AdvancedConfig, layers ...LayeredConfig) *Config { - cfg := &Config{ - General: general, - Advanced: advanced, - Filesystem: FilesystemConfig{ - RO: []string{}, - ROX: []string{}, - RW: []string{}, - RWX: []string{}, - }, - Network: NetworkConfig{ - Enabled: false, - Unrestricted: false, - }, - Env: []string{}, - } - - pathPerms := make(map[string]int) - envSet := make(map[string]bool) - - for _, layer := range layers { - for _, p := range layer.Filesystem.RO { - if pathPerms[p] < 1 { - pathPerms[p] = 1 - } - } - for _, p := range layer.Filesystem.ROX { - if pathPerms[p] < 2 { - pathPerms[p] = 2 - } - } - for _, p := range layer.Filesystem.RW { - if pathPerms[p] < 3 { - pathPerms[p] = 3 - } - } - for _, p := range layer.Filesystem.RWX { - if pathPerms[p] < 4 { - pathPerms[p] = 4 - } - } - - if layer.Network.Enabled { - cfg.Network.Enabled = true - } - if layer.Network.Unrestricted { - cfg.Network.Unrestricted = true - } - cfg.Network.BindTCP = append(cfg.Network.BindTCP, layer.Network.BindTCP...) - cfg.Network.ConnectTCP = append(cfg.Network.ConnectTCP, layer.Network.ConnectTCP...) - - for _, e := range layer.Env { - envSet[e] = true - } - } - - for path, perm := range pathPerms { - switch perm { - case 1: - cfg.Filesystem.RO = append(cfg.Filesystem.RO, path) - case 2: - cfg.Filesystem.ROX = append(cfg.Filesystem.ROX, path) - case 3: - cfg.Filesystem.RW = append(cfg.Filesystem.RW, path) - case 4: - cfg.Filesystem.RWX = append(cfg.Filesystem.RWX, path) - } - } - - for e := range envSet { - cfg.Env = append(cfg.Env, e) - } - - return cfg + return &cfg, nil } diff --git a/internal/sandbox/detect.go b/internal/sandbox/detect.go index fec9608..a11410e 100644 --- a/internal/sandbox/detect.go +++ b/internal/sandbox/detect.go @@ -2,7 +2,6 @@ package sandbox import ( "os/exec" - "strings" "sync" ) @@ -11,27 +10,12 @@ var ( availableOnce sync.Once ) -// IsAvailable checks if landrun is available on the system. +// isAvailable checks if landrun is available on the system. // The result is cached after the first call for performance. -func IsAvailable() bool { +func isAvailable() bool { availableOnce.Do(func() { _, err := exec.LookPath("landrun") available = (err == nil) }) return available } - -// Version returns the landrun version string, or empty if unavailable. -func Version() string { - if !IsAvailable() { - return "" - } - - cmd := exec.Command("landrun", "--version") - out, err := cmd.Output() - if err != nil { - return "" - } - - return strings.TrimSpace(string(out)) -} diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go new file mode 100644 index 0000000..dc7a13b --- /dev/null +++ b/internal/sandbox/sandbox_test.go @@ -0,0 +1,505 @@ +package sandbox + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// TestMain creates a fake landrun binary so IsAvailable() returns true for all tests. +func TestMain(m *testing.M) { + tmpDir, err := os.MkdirTemp("", "sandbox-test-*") + if err != nil { + panic(err) + } + + fakeLandrun := filepath.Join(tmpDir, "landrun") + if err := os.WriteFile(fakeLandrun, []byte("#!/bin/sh\necho 'landrun 0.0.0'\n"), 0755); err != nil { + panic(err) + } + os.Setenv("PATH", tmpDir+":"+os.Getenv("PATH")) + + os.Exit(m.Run()) +} + +// ---- expandPath ---- + +func TestExpandPath(t *testing.T) { + tests := []struct { + name string + pattern string + cwd string + env map[string]string + want string + }{ + { + name: "literal", + pattern: "/etc/passwd", + cwd: "/cwd", + want: "/etc/passwd", + }, + { + name: "CWD", + pattern: "{CWD}/foo", + cwd: "/my/cwd", + want: "/my/cwd/foo", + }, + { + name: "TMPDIR from env", + pattern: "{TMPDIR}", + cwd: "/cwd", + env: map[string]string{"TMPDIR": "/custom/tmp"}, + want: "/custom/tmp", + }, + { + name: "TMPDIR default", + pattern: "{TMPDIR}", + cwd: "/cwd", + env: map[string]string{"TMPDIR": ""}, + want: "/tmp", + }, + { + name: "custom env var", + pattern: "{MY_SANDBOX_TEST_VAR}/bar", + cwd: "/cwd", + env: map[string]string{"MY_SANDBOX_TEST_VAR": "/custom"}, + want: "/custom/bar", + }, + { + name: "unknown var unchanged", + pattern: "{NONEXISTENT_SANDBOX_VAR_XYZ}", + cwd: "/cwd", + want: "{NONEXISTENT_SANDBOX_VAR_XYZ}", + }, + { + name: "XDG_CONFIG_HOME from env", + pattern: "{XDG_CONFIG_HOME}/app", + cwd: "/cwd", + env: map[string]string{"XDG_CONFIG_HOME": "/xdg/config"}, + want: "/xdg/config/app", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + got := expandPath(tc.pattern, tc.cwd) + if got != tc.want { + t.Errorf("expandPath(%q, %q) = %q, want %q", tc.pattern, tc.cwd, got, tc.want) + } + }) + } +} + +// ---- WrapCommand ---- + +func TestWrapCommand(t *testing.T) { + tmpDir := t.TempDir() + rwDir := filepath.Join(tmpDir, "rw") + roDir := filepath.Join(tmpDir, "ro") + if err := os.MkdirAll(rwDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(roDir, 0755); err != nil { + t.Fatal(err) + } + + t.Run("starts with landrun", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"echo", "hi"}, tmpDir) + if len(got) == 0 || got[0] != "landrun" { + t.Errorf("expected landrun as first arg, got %v", got) + } + }) + + t.Run("separator before original command", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"echo", "test"}, tmpDir) + sepIdx := indexOf(got, "--") + if sepIdx == -1 { + t.Fatal("missing -- separator") + } + if got[sepIdx+1] != "echo" || got[sepIdx+2] != "test" { + t.Errorf("expected [echo test] after --, got %v", got[sepIdx+1:]) + } + }) + + t.Run("log level flag", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{LogLevel: "debug"}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertFlagValue(t, got, "--log-level", "debug") + }) + + t.Run("no log level when empty", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{LogLevel: ""}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertDoesNotContain(t, got, "--log-level") + }) + + t.Run("best effort flag", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{BestEffort: true}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertContains(t, got, "--best-effort") + }) + + t.Run("no best effort when false", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{BestEffort: false}, Advanced: AdvancedConfig{}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertDoesNotContain(t, got, "--best-effort") + }) + + t.Run("ldd flag", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{LDD: true}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertContains(t, got, "--ldd") + }) + + t.Run("add-exec flag", func(t *testing.T) { + cfg := &Config{General: GeneralConfig{}, Advanced: AdvancedConfig{AddExec: true}, Network: NetworkConfig{}} + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertContains(t, got, "--add-exec") + }) + + t.Run("rw path included when exists", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Filesystem: FilesystemConfig{RW: []string{rwDir}}, + Network: NetworkConfig{}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertFlagValue(t, got, "--rw", rwDir) + }) + + t.Run("ro path included when exists", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Filesystem: FilesystemConfig{RO: []string{roDir}}, + Network: NetworkConfig{}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertFlagValue(t, got, "--ro", roDir) + }) + + t.Run("nonexistent path excluded", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Filesystem: FilesystemConfig{RW: []string{"/nonexistent/path/that/does/not/exist/xyz"}}, + Network: NetworkConfig{}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertDoesNotContain(t, got, "--rw") + }) + + t.Run("CWD template expanded to real path", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Filesystem: FilesystemConfig{RW: []string{"{CWD}"}}, + Network: NetworkConfig{}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertFlagValue(t, got, "--rw", tmpDir) + }) + + t.Run("unrestricted network", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Network: NetworkConfig{Enabled: true, Unrestricted: true}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertContains(t, got, "--unrestricted-network") + assertDoesNotContain(t, got, "--connect-tcp") + assertDoesNotContain(t, got, "--bind-tcp") + }) + + t.Run("restricted network ports", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Network: NetworkConfig{ + Enabled: true, + ConnectTCP: []string{"443", "80"}, + BindTCP: []string{"8080"}, + }, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertFlagValue(t, got, "--connect-tcp", "443") + assertFlagValue(t, got, "--connect-tcp", "80") + assertFlagValue(t, got, "--bind-tcp", "8080") + assertDoesNotContain(t, got, "--unrestricted-network") + }) + + t.Run("network disabled ignores ports", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Network: NetworkConfig{Enabled: false, ConnectTCP: []string{"443"}}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertDoesNotContain(t, got, "--connect-tcp") + assertDoesNotContain(t, got, "--unrestricted-network") + }) + + t.Run("env vars", func(t *testing.T) { + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Network: NetworkConfig{}, + Env: []string{"HOME", "PATH"}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + assertFlagValue(t, got, "--env", "HOME") + assertFlagValue(t, got, "--env", "PATH") + }) + + t.Run("parent path sorted before child", func(t *testing.T) { + parent := filepath.Join(tmpDir, "parent") + child := filepath.Join(tmpDir, "parent", "child") + if err := os.MkdirAll(child, 0755); err != nil { + t.Fatal(err) + } + cfg := &Config{ + General: GeneralConfig{}, + Advanced: AdvancedConfig{}, + Filesystem: FilesystemConfig{RW: []string{child, parent}}, // child listed first + Network: NetworkConfig{}, + } + got := WrapCommand(cfg, []string{"sh"}, tmpDir) + parentIdx := indexOf(got, parent) + childIdx := indexOf(got, child) + if parentIdx == -1 || childIdx == -1 { + t.Fatal("parent or child path missing from args") + } + if parentIdx > childIdx { + t.Errorf("parent (idx %d) should appear before child (idx %d) in args", parentIdx, childIdx) + } + }) +} + +// ---- checkPath ---- + +func TestCheckPath(t *testing.T) { + tmpDir := t.TempDir() + rwDir := filepath.Join(tmpDir, "rw") + roDir := filepath.Join(tmpDir, "ro") + if err := os.MkdirAll(rwDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(roDir, 0755); err != nil { + t.Fatal(err) + } + + cfg := &Config{ + Filesystem: FilesystemConfig{ + RO: []string{roDir}, + RW: []string{rwDir}, + }, + } + + tests := []struct { + name string + path string + write bool + wantErr bool + }{ + {"rw path read allowed", filepath.Join(rwDir, "file.txt"), false, false}, + {"rw path write allowed", filepath.Join(rwDir, "file.txt"), true, false}, + {"ro path read allowed", filepath.Join(roDir, "file.txt"), false, false}, + {"ro path write denied", filepath.Join(roDir, "file.txt"), true, true}, + {"outside path read denied", "/outside/path/xyz", false, true}, + {"outside path write denied", "/outside/path/xyz", true, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := checkPath(cfg, tc.path, tc.write, tmpDir) + if (err != nil) != tc.wantErr { + t.Errorf("checkPath(%q, write=%v) error = %v, wantErr %v", tc.path, tc.write, err, tc.wantErr) + } + }) + } + + t.Run("CWD template resolved", func(t *testing.T) { + cfg2 := &Config{Filesystem: FilesystemConfig{RW: []string{"{CWD}"}}} + err := checkPath(cfg2, filepath.Join(tmpDir, "newfile.txt"), true, tmpDir) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("RWX grants write access", func(t *testing.T) { + rwxDir := filepath.Join(tmpDir, "rwx") + if err := os.MkdirAll(rwxDir, 0755); err != nil { + t.Fatal(err) + } + cfg3 := &Config{Filesystem: FilesystemConfig{RWX: []string{rwxDir}}} + if err := checkPath(cfg3, filepath.Join(rwxDir, "bin"), true, tmpDir); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) +} + +// ---- LoadSandbox ---- + +func TestLoadSandbox(t *testing.T) { + t.Run("full config", func(t *testing.T) { + yaml := []byte(` +general: + best_effort: true + log_level: debug +filesystem: + ro: + - /etc/passwd + rox: + - /usr + rw: + - "{CWD}" + rwx: + - "{OLLIE_CFG_PATH}" +network: + enabled: true + unrestricted: false + bind_tcp: + - "8080" + connect_tcp: + - "443" +env: + - HOME + - PATH +advanced: + ldd: true + add_exec: false +`) + cfg, err := LoadSandbox(bytes.NewReader(yaml)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.General.BestEffort { + t.Error("BestEffort should be true") + } + if cfg.General.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", cfg.General.LogLevel, "debug") + } + if !containsStr(cfg.Filesystem.RO, "/etc/passwd") { + t.Error("RO missing /etc/passwd") + } + if !containsStr(cfg.Filesystem.ROX, "/usr") { + t.Error("ROX missing /usr") + } + if !containsStr(cfg.Filesystem.RW, "{CWD}") { + t.Error("RW missing {CWD}") + } + if !containsStr(cfg.Filesystem.RWX, "{OLLIE_CFG_PATH}") { + t.Error("RWX missing {OLLIE_CFG_PATH}") + } + if !cfg.Network.Enabled { + t.Error("Network.Enabled should be true") + } + if cfg.Network.Unrestricted { + t.Error("Network.Unrestricted should be false") + } + if !containsStr(cfg.Network.BindTCP, "8080") { + t.Error("BindTCP missing 8080") + } + if !containsStr(cfg.Network.ConnectTCP, "443") { + t.Error("ConnectTCP missing 443") + } + if !containsStr(cfg.Env, "HOME") || !containsStr(cfg.Env, "PATH") { + t.Error("Env missing HOME or PATH") + } + if !cfg.Advanced.LDD { + t.Error("Advanced.LDD should be true") + } + if cfg.Advanced.AddExec { + t.Error("Advanced.AddExec should be false") + } + }) + + t.Run("empty yaml produces zero config", func(t *testing.T) { + cfg, err := LoadSandbox(bytes.NewReader(nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.General.BestEffort || cfg.General.LogLevel != "" { + t.Error("expected zero GeneralConfig") + } + if len(cfg.Filesystem.RW) != 0 || len(cfg.Env) != 0 { + t.Error("expected empty filesystem and env") + } + }) + + t.Run("invalid yaml returns error", func(t *testing.T) { + _, err := LoadSandbox(bytes.NewReader([]byte("{ not: valid: yaml: ["))) + if err == nil { + t.Error("expected error for invalid yaml") + } + }) + + t.Run("partial config leaves other fields zero", func(t *testing.T) { + yaml := []byte(` +env: + - TERM +`) + cfg, err := LoadSandbox(bytes.NewReader(yaml)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !containsStr(cfg.Env, "TERM") { + t.Error("Env missing TERM") + } + if len(cfg.Filesystem.RW) != 0 { + t.Error("expected empty RW") + } + }) +} + +// ---- helpers ---- + +func assertContains(t *testing.T, args []string, s string) { + t.Helper() + if indexOf(args, s) == -1 { + t.Errorf("args %v does not contain %q", args, s) + } +} + +func assertDoesNotContain(t *testing.T, args []string, s string) { + t.Helper() + if indexOf(args, s) != -1 { + t.Errorf("args %v should not contain %q", args, s) + } +} + +func assertFlagValue(t *testing.T, args []string, flag, value string) { + t.Helper() + for i, a := range args { + if a == flag && i+1 < len(args) && args[i+1] == value { + return + } + } + t.Errorf("args %v missing %s %s", args, flag, value) +} + +func indexOf(args []string, s string) int { + for i, a := range args { + if a == s { + return i + } + } + return -1 +} + +func containsStr(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} diff --git a/internal/sandbox/wrapper.go b/internal/sandbox/wrapper.go index b62acba..7a7c170 100644 --- a/internal/sandbox/wrapper.go +++ b/internal/sandbox/wrapper.go @@ -17,7 +17,7 @@ type pathEntry struct { // WrapCommand wraps a command with landrun based on the configuration. // Returns the wrapped command args, or the original command if landrun is unavailable. func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string { - if !IsAvailable() { + if !isAvailable() { return originalCmd } @@ -40,25 +40,25 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string { var entries []pathEntry for _, path := range cfg.Filesystem.RO { - expanded := ExpandPath(path, cwd) + expanded := expandPath(path, cwd) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--ro"}) } } for _, path := range cfg.Filesystem.ROX { - expanded := ExpandPath(path, cwd) + expanded := expandPath(path, cwd) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--rox"}) } } for _, path := range cfg.Filesystem.RW { - expanded := ExpandPath(path, cwd) + expanded := expandPath(path, cwd) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--rw"}) } } for _, path := range cfg.Filesystem.RWX { - expanded := ExpandPath(path, cwd) + expanded := expandPath(path, cwd) if pathExists(expanded) { entries = append(entries, pathEntry{expanded, "--rwx"}) } @@ -95,7 +95,7 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string { args = append(args, originalCmd...) // Wrap with superpowers run-session if superpowerd is running - if IsSuperpowerdRunning() { + if isSuperpowerdRunning() { if superpowersPath, err := exec.LookPath("superpowers"); err == nil { args = append([]string{args[0], "--env", "SUPERPOWERD_SESSION_TOKEN"}, args[1:]...) args = append([]string{superpowersPath, "run-session", "--"}, args...) @@ -105,8 +105,8 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string { return args } -// IsSuperpowerdRunning checks if superpowerd is running by testing socket connectivity -func IsSuperpowerdRunning() bool { +// isSuperpowerdRunning checks if superpowerd is running by testing socket connectivity +func isSuperpowerdRunning() bool { socketDir := os.Getenv("SUPERPOWERD_SOCKET_DIR") if socketDir == "" { socketDir = os.Getenv("XDG_RUNTIME_DIR") diff --git a/pkg/backend/anthropic.go b/pkg/backend/anthropic.go index 5096148..a1bb68a 100644 --- a/pkg/backend/anthropic.go +++ b/pkg/backend/anthropic.go @@ -15,13 +15,14 @@ const anthropicDefaultMaxTokens = 8192 // AnthropicBackend speaks the Anthropic Messages API. type AnthropicBackend struct { - apiKey string - model string - client *http.Client + apiKey string + model string + client *http.Client + BaseURL string // default: https://api.anthropic.com; override for testing } func NewAnthropic(apiKey string) *AnthropicBackend { - b := &AnthropicBackend{apiKey: apiKey, client: &http.Client{}} + b := &AnthropicBackend{apiKey: apiKey, client: &http.Client{}, BaseURL: "https://api.anthropic.com"} b.model = b.DefaultModel() return b } @@ -110,7 +111,7 @@ func (b *AnthropicBackend) ChatStream(ctx context.Context, messages []Message, t return nil, err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(data)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, b.BaseURL+"/v1/messages", bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/pkg/backend/backend_test.go b/pkg/backend/backend_test.go new file mode 100644 index 0000000..39f4905 --- /dev/null +++ b/pkg/backend/backend_test.go @@ -0,0 +1,135 @@ +package backend_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "ollie/pkg/backend" +) + +// stubBackend is a minimal Backend used to verify the contract. +type stubBackend struct { + model string +} + +func (s *stubBackend) Name() string { return "stub" } +func (s *stubBackend) DefaultModel() string { return "stub-default" } +func (s *stubBackend) Model() string { return s.model } +func (s *stubBackend) SetModel(m string) { s.model = m } +func (s *stubBackend) ContextLength(_ context.Context) int { return 4096 } +func (s *stubBackend) Models(_ context.Context) []string { return []string{"stub-default"} } +func (s *stubBackend) ChatStream(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) { + ch := make(chan backend.StreamEvent, 1) + ch <- backend.StreamEvent{Done: true, StopReason: "end_turn"} + close(ch) + return ch, nil +} + +// checkContract verifies Backend invariants. Call it with any Backend implementation. +func checkContract(t *testing.T, b backend.Backend) { + t.Helper() + + if b.Name() == "" { + t.Error("Name() must be non-empty") + } + if b.DefaultModel() == "" { + t.Error("DefaultModel() must be non-empty") + } + + b.SetModel("test-model") + if got := b.Model(); got != "test-model" { + t.Errorf("Model() = %q after SetModel; want %q", got, "test-model") + } + + msgs := []backend.Message{{Role: "user", Content: "ping"}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ch, err := b.ChatStream(ctx, msgs, nil, backend.GenerationParams{}) + if err != nil { + t.Fatalf("ChatStream returned error: %v", err) + } + var gotDone bool + for ev := range ch { + if ev.Done { + gotDone = true + } + } + if !gotDone { + t.Error("ChatStream channel closed without a Done==true event") + } +} + +func TestStubBackendContract(t *testing.T) { + checkContract(t, &stubBackend{model: "stub-default"}) +} + +func TestOllamaContract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.Write([]byte(`{"done":true,"done_reason":"stop","message":{"role":"assistant","content":""}}` + "\n")) + })) + defer srv.Close() + checkContract(t, backend.NewOllama(srv.URL)) +} + +func TestOpenAIContract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Write([]byte("data: [DONE]\n\n")) + })) + defer srv.Close() + checkContract(t, backend.NewOpenAI("openai", srv.URL, "fake-key")) +} + +func TestCopilotContract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Write([]byte("data: [DONE]\n\n")) + })) + defer srv.Close() + // Copilot is an OpenAI backend; exercise it via NewOpenAI with the copilot name. + checkContract(t, backend.NewOpenAI("copilot", srv.URL, "fake-token")) +} + +func TestAnthropicContract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Write([]byte( + "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":0}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + )) + })) + defer srv.Close() + b := backend.NewAnthropic("fake-key") + b.BaseURL = srv.URL + checkContract(t, b) +} + +func TestCodeWhispererContract(t *testing.T) { + // Static auth has no DefaultEndpoint, so ChatStream terminates immediately + // with Done==true via the endpoint-resolution error path. This verifies that + // the contract invariant — stream always terminates with Done — holds even + // on error paths. + b, err := backend.NewCodeWhisperer("fake-token") + if err != nil { + t.Fatalf("NewCodeWhisperer: %v", err) + } + checkContract(t, b) +} + +func TestRateLimitError(t *testing.T) { + e := &backend.RateLimitError{RetryAfter: 30 * time.Second, Message: "quota exceeded"} + want := "rate limited (retry after 30s): quota exceeded" + if got := e.Error(); got != want { + t.Errorf("Error() = %q; want %q", got, want) + } + + e2 := &backend.RateLimitError{Message: "over limit"} + want2 := "rate limited: over limit" + if got := e2.Error(); got != want2 { + t.Errorf("Error() = %q; want %q", got, want2) + } +} diff --git a/pkg/tools/execute/code.go b/pkg/tools/execute/code.go index dcc3a6a..0101d09 100644 --- a/pkg/tools/execute/code.go +++ b/pkg/tools/execute/code.go @@ -5,7 +5,12 @@ import ( "encoding/json" "fmt" "io" + "os" + "path/filepath" + "ollie/internal/sandbox" + "ollie/pkg/paths" + "regexp" "strings" ) @@ -132,8 +137,17 @@ func (e *Server) ValidateCode(code, language string) error { return nil } -func loadLayeredConfig(name string) (*sandbox.Config, error) { - return sandbox.LoadMerged(name) +func loadSandboxConfig(name string) (*sandbox.Config, error) { + if name == "" { + name = "default" + } + path := filepath.Join(paths.CfgDir(), "sandbox", name+".yaml") + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("sandbox %q not found: %w", name, err) + } + defer f.Close() + return sandbox.LoadSandbox(f) } type limitedWriter struct { diff --git a/pkg/tools/execute/server.go b/pkg/tools/execute/server.go index d9f37f7..628561c 100644 --- a/pkg/tools/execute/server.go +++ b/pkg/tools/execute/server.go @@ -278,7 +278,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti } } - cfg, err := loadLayeredConfig(sandboxName) + cfg, err := loadSandboxConfig(sandboxName) if err != nil { return "", err }