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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-09 22:19:46 +02:00
parent edee1a5889
commit c7d2684cfc
6 changed files with 551 additions and 36 deletions

17
go.mod
View File

@ -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

19
go.sum
View File

@ -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=

382
internal/sandbox/config.go Normal file
View File

@ -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/<name>.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
}

View File

@ -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))
}

130
internal/sandbox/wrapper.go Normal file
View File

@ -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
}

View File

@ -17,7 +17,7 @@ import (
"syscall"
"time"
"anvillm/pkg/sandbox"
"ollie/internal/sandbox"
)
const defaultMaxOutputChars = 8000