From c7d2684cfc6d2eb7bd28af6825e63ab1c823023a Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 9 Apr 2026 22:19:46 +0200 Subject: [PATCH] sandbox: copy from anvillm, break dependency Vendor anvillm/pkg/sandbox into internal/sandbox, updating config paths from ~/.config/anvillm to ~/.config/ollie. Remove anvillm from go.mod. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 17 +- go.sum | 19 -- internal/sandbox/config.go | 382 ++++++++++++++++++++++++++++++++++ internal/sandbox/detect.go | 37 ++++ internal/sandbox/wrapper.go | 130 ++++++++++++ pkg/tools/execute/executor.go | 2 +- 6 files changed, 551 insertions(+), 36 deletions(-) create mode 100644 internal/sandbox/config.go create mode 100644 internal/sandbox/detect.go create mode 100644 internal/sandbox/wrapper.go diff --git a/go.mod b/go.mod index d17fd44..aa1136e 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,5 @@ go 1.25.6 require ( 9fans.net/go v0.0.7 - anvillm v0.0.0-00010101000000-000000000000 - github.com/hymkor/go-multiline-ny v0.22.4 - github.com/mattn/go-runewidth v0.0.19 - github.com/mattn/go-tty v0.0.7 - github.com/nyaosorg/go-readline-ny v1.14.1 - github.com/nyaosorg/go-ttyadapter v0.3.0 + gopkg.in/yaml.v3 v3.0.1 ) - -require ( - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.41.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -replace anvillm => ../anvillm/main diff --git a/go.sum b/go.sum index 97b32b8..1137047 100644 --- a/go.sum +++ b/go.sum @@ -2,23 +2,7 @@ 9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/hymkor/go-multiline-ny v0.22.4 h1:Ag2rkBpDnr3jp+AHe1CuXSOQ4AQ8D0+gBNVf+BAX+/0= -github.com/hymkor/go-multiline-ny v0.22.4/go.mod h1:v2lqQooHVAO53WICAIbgTn74bQtzsg4tFn29d+7JPwY= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= -github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= -github.com/nyaosorg/go-readline-ny v1.14.1 h1:bWyXpR6jRaCXysx4bnioxk36+YjQ6dypHKMjHnzIXdk= -github.com/nyaosorg/go-readline-ny v1.14.1/go.mod h1:/BDf3/H/AScnvey4LoDws1bjTZDB76EE7uKnW2apoKU= -github.com/nyaosorg/go-ttyadapter v0.3.0 h1:/Y7+rGJ0LEcs+AExevwNmND2VJvvpBmgbMuCbntKq3c= -github.com/nyaosorg/go-ttyadapter v0.3.0/go.mod h1:w6ySb/Y8rpr0uIju4vN/TMRHC/6ayabORHmEVs6d/qE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -41,9 +25,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/internal/sandbox/config.go b/internal/sandbox/config.go new file mode 100644 index 0000000..1feaa91 --- /dev/null +++ b/internal/sandbox/config.go @@ -0,0 +1,382 @@ +package sandbox + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config represents the global sandbox configuration +type Config struct { + General GeneralConfig `yaml:"general"` + Filesystem FilesystemConfig `yaml:"filesystem"` + Network NetworkConfig `yaml:"network"` + Env []string `yaml:"env"` + 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"` + LogLevel string `yaml:"log_level"` +} + +// FilesystemConfig contains filesystem permission settings +type FilesystemConfig struct { + RO []string `yaml:"ro"` // Read-only + ROX []string `yaml:"rox"` // Read-only with execute + RW []string `yaml:"rw"` // Read-write + RWX []string `yaml:"rwx"` // Read-write with execute +} + +// NetworkConfig contains network permission settings +type NetworkConfig struct { + Enabled bool `yaml:"enabled"` + Unrestricted bool `yaml:"unrestricted"` + BindTCP []string `yaml:"bind_tcp"` + ConnectTCP []string `yaml:"connect_tcp"` +} + +// AdvancedConfig contains advanced landrun settings +type AdvancedConfig struct { + LDD bool `yaml:"ldd"` + 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}", + "{HOME}/.claude", + "{HOME}/.kiro", + "{HOME}/.config/ollie", + "{HOME}/.claude.json", + }, + RWX: []string{}, + }, + 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", + }, + Advanced: AdvancedConfig{ + LDD: false, + AddExec: true, + }, + } +} + +// ConfigPath returns the path to the sandbox config file +func ConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "ollie", "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 { + s := pattern + s = strings.ReplaceAll(s, "{CWD}", cwd) + + re := regexp.MustCompile(`\{([A-Z_][A-Z0-9_]*)\}`) + s = re.ReplaceAllStringFunc(s, func(match string) string { + varName := match[1 : len(match)-1] + + home, _ := os.UserHomeDir() + switch varName { + case "HOME": + if home != "" { + return home + } + case "TMPDIR": + if tmpdir := os.Getenv("TMPDIR"); tmpdir != "" { + return tmpdir + } + return "/tmp" + case "XDG_CONFIG_HOME": + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return xdg + } + return filepath.Join(home, ".config") + case "XDG_DATA_HOME": + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return xdg + } + return filepath.Join(home, ".local/share") + case "XDG_CACHE_HOME": + if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" { + return xdg + } + return filepath.Join(home, ".cache") + case "XDG_STATE_HOME": + if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { + return xdg + } + return filepath.Join(home, ".local/state") + case "XDG_RUNTIME_DIR": + if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { + return xdg + } + return fmt.Sprintf("/run/user/%d", os.Getuid()) + } + + if val := os.Getenv(varName); val != "" { + return val + } + return match + }) + + return s +} + +// 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", + }, + } +} + +// LoadSandbox loads a named sandbox layer config from ~/.config/ollie/sandbox/.yaml +func LoadSandbox(name string) (LayeredConfig, error) { + if err := validateName(name); err != nil { + return LayeredConfig{}, fmt.Errorf("invalid sandbox name: %w", err) + } + + home, _ := os.UserHomeDir() + path := filepath.Join(home, ".config", "ollie", "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) + } + if err != nil { + return LayeredConfig{}, err + } + + var cfg LayeredConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return LayeredConfig{}, fmt.Errorf("parse %s: %w", path, 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 +} diff --git a/internal/sandbox/detect.go b/internal/sandbox/detect.go new file mode 100644 index 0000000..fec9608 --- /dev/null +++ b/internal/sandbox/detect.go @@ -0,0 +1,37 @@ +package sandbox + +import ( + "os/exec" + "strings" + "sync" +) + +var ( + available bool + availableOnce sync.Once +) + +// IsAvailable checks if landrun is available on the system. +// The result is cached after the first call for performance. +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/wrapper.go b/internal/sandbox/wrapper.go new file mode 100644 index 0000000..b62acba --- /dev/null +++ b/internal/sandbox/wrapper.go @@ -0,0 +1,130 @@ +package sandbox + +import ( + "net" + "os" + "os/exec" + "path/filepath" + "sort" +) + +// pathEntry holds a path with its permission type +type pathEntry struct { + path string + flag string // --ro, --rox, --rw, --rwx +} + +// 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() { + return originalCmd + } + + args := []string{"landrun"} + + if cfg.General.LogLevel != "" { + args = append(args, "--log-level", cfg.General.LogLevel) + } + + if cfg.General.BestEffort { + args = append(args, "--best-effort") + } + + if cfg.Advanced.LDD { + args = append(args, "--ldd") + } + if cfg.Advanced.AddExec { + args = append(args, "--add-exec") + } + + var entries []pathEntry + for _, path := range cfg.Filesystem.RO { + expanded := ExpandPath(path, cwd) + if pathExists(expanded) { + entries = append(entries, pathEntry{expanded, "--ro"}) + } + } + for _, path := range cfg.Filesystem.ROX { + expanded := ExpandPath(path, cwd) + if pathExists(expanded) { + entries = append(entries, pathEntry{expanded, "--rox"}) + } + } + for _, path := range cfg.Filesystem.RW { + expanded := ExpandPath(path, cwd) + if pathExists(expanded) { + entries = append(entries, pathEntry{expanded, "--rw"}) + } + } + for _, path := range cfg.Filesystem.RWX { + expanded := ExpandPath(path, cwd) + if pathExists(expanded) { + entries = append(entries, pathEntry{expanded, "--rwx"}) + } + } + + // Sort so parents come before children + sort.Slice(entries, func(i, j int) bool { + if len(entries[i].path) != len(entries[j].path) { + return len(entries[i].path) < len(entries[j].path) + } + return entries[i].path < entries[j].path + }) + + for _, e := range entries { + args = append(args, e.flag, e.path) + } + + if cfg.Network.Unrestricted { + args = append(args, "--unrestricted-network") + } else if cfg.Network.Enabled { + for _, port := range cfg.Network.BindTCP { + args = append(args, "--bind-tcp", port) + } + for _, port := range cfg.Network.ConnectTCP { + args = append(args, "--connect-tcp", port) + } + } + + for _, name := range cfg.Env { + args = append(args, "--env", name) + } + + args = append(args, "--") + args = append(args, originalCmd...) + + // Wrap with superpowers run-session if superpowerd is running + 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...) + } + } + + return args +} + +// 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") + if socketDir == "" { + return false + } + socketDir = filepath.Join(socketDir, "superpowerd") + } + conn, err := net.Dial("unixpacket", filepath.Join(socketDir, "superpowerd.sock")) + if err != nil { + return false + } + conn.Close() + return true +} + +// pathExists checks if a file or directory exists +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/pkg/tools/execute/executor.go b/pkg/tools/execute/executor.go index c30788b..4ac86f4 100644 --- a/pkg/tools/execute/executor.go +++ b/pkg/tools/execute/executor.go @@ -17,7 +17,7 @@ import ( "syscall" "time" - "anvillm/pkg/sandbox" + "ollie/internal/sandbox" ) const defaultMaxOutputChars = 8000