From 528a482fe87bb64bc1932f3b56d2ae450eb3e8aa Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 11 Feb 2026 08:32:14 +0100 Subject: [PATCH] basic sandboxing support with landrun, fail fast --- README.md | 129 +++++++++++++++++++++++ go.mod | 9 +- go.sum | 4 + internal/backend/tmux/session.go | 24 ++++- internal/backend/tmux/tmux.go | 36 ++++++- internal/backends/claude.go | 2 +- internal/backends/kirocli.go | 1 + internal/sandbox/config.go | 171 +++++++++++++++++++++++++++++++ internal/sandbox/detect.go | 37 +++++++ internal/sandbox/wrapper.go | 158 ++++++++++++++++++++++++++++ internal/ui/sandbox.go | 150 +++++++++++++++++++++++++++ main.go | 7 +- 12 files changed, 718 insertions(+), 10 deletions(-) create mode 100644 internal/sandbox/config.go create mode 100644 internal/sandbox/detect.go create mode 100644 internal/sandbox/wrapper.go create mode 100644 internal/ui/sandbox.go diff --git a/README.md b/README.md index 4e2c4c3..ab09942 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,135 @@ Backends create persistent tmux sessions named `anvillm-`: These sessions persist across AnviLLM restarts and are cleaned up on exit. +## Sandboxing + +AnviLLM uses [landrun](https://github.com/landlock-lsm/landrun) to sandbox backend processes for enhanced security. Sandboxing restricts filesystem and network access for LLM-controlled processes while allowing backends to run with full permissions (`--dangerously-skip-permissions`, `--trust-all-tools`) within the sandbox. + +### How It Works + +``` +landrun --rw /project --rox /usr --connect-tcp 443 -- claude --dangerously-skip-permissions + ↑ ↑ + Sandbox layer (restricts access) Backend runs with full permissions + (but only within sandbox) +``` + +### Quick Start + +Sandboxing is **enabled by default** with a locked-down policy: +- **Filesystem**: Read/write access to session working directory only +- **System binaries**: Read-only + execute access to `/usr`, `/lib`, `/bin`, `/sbin` +- **Network**: HTTPS (port 443) for API calls +- **Config files**: Access to `~/.claude`, `~/.kiro`, `~/.config/anvillm` + +### Requirements + +- **landrun**: Install with `go install github.com/landlock-lsm/landrun@latest` +- **Linux Kernel**: 5.13+ with Landlock support +- **Fallback**: Runs unsandboxed if landrun unavailable (best-effort mode) + +### Configuration + +Configure sandboxing via the `Sandbox` tag command in the main `/AnviLLM/` window: + +1. Click **Sandbox** to open the configuration window +2. Click **Edit** to modify `~/.config/anvillm/sandbox.yaml` +3. Click **Reload** to reload configuration from disk +4. Create new sessions to apply changes + +Configuration file: `~/.config/anvillm/sandbox.yaml` + +**Important**: Changes apply to NEW sessions only. Active sessions are NOT affected. + +### Path Templates + +Use these templates in filesystem paths: +- `{CWD}` - Session working directory +- `{HOME}` - User home directory (`~`) +- `{TMPDIR}` - Temporary directory (`/tmp`) + +### Common Configurations + +#### Development (Permissive) +```yaml +network: + enabled: true + connect_tcp: + - "443" + - "80" + - "3000-9000" + +filesystem: + rw: + - "{CWD}" + - "{HOME}/.npm" + - "{HOME}/.cache" + - "{HOME}/.local" + rox: + - "/usr" + - "/lib" + - "/bin" +``` + +#### Code Review (Restrictive) +```yaml +network: + enabled: false + +filesystem: + ro: + - "{CWD}" # Read-only project access + rw: + - "{TMPDIR}" + rox: + - "/usr" + - "/bin" +``` + +#### Disabled (Testing Only) +```yaml +general: + enabled: false +``` + +### Security Model + +**Default protections**: +- ✓ Cannot read sensitive files outside CWD (e.g., `~/.ssh/id_rsa`, `~/.aws/credentials`) +- ✓ Cannot modify system files +- ✓ Cannot make unauthorized network connections +- ✓ Cannot access files in other users' directories + +**User can still**: +- Modify files in session CWD (by design - project workspace) +- Weaken sandboxing via config (user choice) + +### Troubleshooting + +**Landrun not available?** +- Install: `go install github.com/landlock-lsm/landrun@latest` +- Check status: Click `Sandbox` → `Status` in AnviLLM + +**Network errors?** +- Ensure `connect_tcp = ["443"]` in config for API calls +- Or set `unrestricted = true` for full network access + +**File access denied?** +- Add required paths to `rw` or `ro` in config +- Example for SSH keys: `ro = ["{HOME}/.ssh"]` +- Example for NPM: `rw = ["{HOME}/.npm", "{HOME}/.cache"]` + +**Check sandbox status**: +```sh +# View current config +cat ~/.config/anvillm/sandbox.yaml + +# Test landrun availability +landrun --version +``` + +For more details, see [landrun documentation](https://github.com/landlock-lsm/landrun). + ## Troubleshooting ### "Backend not found" error diff --git a/go.mod b/go.mod index 21f08e0..d0150dc 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,10 @@ module anvillm -go 1.21 +go 1.21.0 -require 9fans.net/go v0.0.7 +toolchain go1.24.3 + +require ( + 9fans.net/go v0.0.7 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum index d81a876..1137047 100644 --- a/go.sum +++ b/go.sum @@ -32,3 +32,7 @@ golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go index 62d1d24..c981797 100644 --- a/internal/backend/tmux/session.go +++ b/internal/backend/tmux/session.go @@ -142,10 +142,21 @@ func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error return fmt.Errorf("session closed") } s.output.Write(data) + output := s.output.String() + + // Check for error patterns that indicate launch failure + if strings.Contains(output, "Error:") || + strings.Contains(output, "ERROR") || + strings.Contains(output, "Failed to apply sandbox") || + strings.Contains(output, "missing kernel Landlock support") { + fmt.Fprintf(os.Stderr, "Error: backend failed to launch\n") + fmt.Fprintf(os.Stderr, "Backend output:\n%s\n", output) + return fmt.Errorf("backend launch failed") + } // If we have a startup handler and it hasn't completed, call it if !startupDone && s.startupHandler != nil { - keys, done := s.startupHandler.HandleStartup(s.output.String()) + keys, done := s.startupHandler.HandleStartup(output) if keys != "" { debug.Log("[session %s] startup handler sending keys: %q", s.id, keys) if err := sendKeys(s.target(), keys); err != nil { @@ -160,13 +171,20 @@ func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error } // Check if ready (only after startup is done) - if startupDone && s.detector.IsReady(s.output.String()) { + if startupDone && s.detector.IsReady(output) { debug.Log("[session %s] ready detected", s.id) return nil } case <-time.After(time.Until(deadline)): if time.Now().After(deadline) { - debug.Log("[session %s] timeout, current output: %s", s.id, s.output.String()) + output := s.output.String() + debug.Log("[session %s] timeout, current output: %s", s.id, output) + if output != "" { + fmt.Fprintf(os.Stderr, "Error: timeout waiting for backend to be ready\n") + fmt.Fprintf(os.Stderr, "Backend output:\n%s\n", output) + } else { + fmt.Fprintf(os.Stderr, "Error: timeout waiting for backend to be ready (no output received)\n") + } return fmt.Errorf("timeout waiting for ready") } } diff --git a/internal/backend/tmux/tmux.go b/internal/backend/tmux/tmux.go index 34715f3..031e4dd 100644 --- a/internal/backend/tmux/tmux.go +++ b/internal/backend/tmux/tmux.go @@ -4,6 +4,7 @@ package tmux import ( "anvillm/internal/backend" "anvillm/internal/debug" + "anvillm/internal/sandbox" "context" "crypto/rand" "fmt" @@ -78,6 +79,7 @@ func New(cfg Config) backend.Backend { if cfg.TmuxSize.Cols == 0 { cfg.TmuxSize.Cols = 120 } + return &Backend{ cfg: cfg, tmuxSession: fmt.Sprintf("anvillm-%s", cfg.Name), @@ -115,6 +117,14 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio debug.Log("[session %s] creating window in tmux session %s", id, b.tmuxSession) + // Load sandbox configuration fresh for each session + sandboxCfg, err := sandbox.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading sandbox config: %v (using defaults)\n", err) + debug.Log("[session %s] failed to load sandbox config: %v, using defaults", id, err) + sandboxCfg = sandbox.DefaultConfig() + } + // 1. Ensure persistent tmux session exists if err := b.ensureTmuxSession(); err != nil { return nil, fmt.Errorf("failed to ensure tmux session: %w", err) @@ -164,9 +174,29 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio fifoOpenCh <- f }() - // 7. Start the command in tmux window + // 7. Wrap command with landrun if sandboxing enabled + command := b.cfg.Command + if sandboxCfg.General.Enabled { + if !sandbox.IsAvailable() { + if sandboxCfg.General.BestEffort { + fmt.Fprintf(os.Stderr, "Warning: landrun not available, running unsandboxed (best-effort mode)\n") + } else { + os.Remove(fifoPath) + killWindow(b.tmuxSession, windowName) + fmt.Fprintf(os.Stderr, "Error: sandboxing enabled but landrun not available\n") + fmt.Fprintf(os.Stderr, "Install landrun or enable best_effort mode in ~/.config/anvillm/sandbox.yaml\n") + return nil, fmt.Errorf("sandboxing enabled but landrun not available") + } + } + command = sandbox.WrapCommand(sandboxCfg, command, cwd) + debug.Log("[session %s] sandboxed command: %v", id, command) + } else { + debug.Log("[session %s] unsandboxed command: %v", id, command) + } + + // 8. Build command string for tmux cmdStr := "" - for i, arg := range b.cfg.Command { + for i, arg := range command { if i > 0 { cmdStr += " " } @@ -179,7 +209,7 @@ func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Sessio } // Change to working directory first - if err := sendKeys(target, "cd", fmt.Sprintf("\"%s\"", cwd), "C-m"); err != nil { + if err := sendKeys(target, fmt.Sprintf("cd \"%s\"", cwd), "C-m"); err != nil { os.Remove(fifoPath) killWindow(b.tmuxSession, windowName) return nil, fmt.Errorf("failed to change directory: %w", err) diff --git a/internal/backends/claude.go b/internal/backends/claude.go index 7083986..77c4f2e 100644 --- a/internal/backends/claude.go +++ b/internal/backends/claude.go @@ -18,7 +18,7 @@ import ( // NewClaude creates a claude CLI backend // // Uses tmux backend to handle interactive TUI dialogs programmatically. -// The startup handler automatically accepts the --dangerously-skip-permissions dialog. +// Runs with --dangerously-skip-permissions because landrun provides the actual sandboxing. // // Sessions are automatically saved by Claude to ~/.claude/projects//.jsonl func NewClaude() backend.Backend { diff --git a/internal/backends/kirocli.go b/internal/backends/kirocli.go index f42bb6f..8ea1bd3 100644 --- a/internal/backends/kirocli.go +++ b/internal/backends/kirocli.go @@ -15,6 +15,7 @@ import ( ) // NewKiroCLI creates a kiro-cli backend +// Runs with --trust-all-tools because landrun provides the actual sandboxing func NewKiroCLI() backend.Backend { return tmux.New(tmux.Config{ Name: "kiro-cli", diff --git a/internal/sandbox/config.go b/internal/sandbox/config.go new file mode 100644 index 0000000..4ff2cb7 --- /dev/null +++ b/internal/sandbox/config.go @@ -0,0 +1,171 @@ +package sandbox + +import ( + "os" + "path/filepath" + "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"` + Advanced AdvancedConfig `yaml:"advanced"` +} + +// GeneralConfig contains general sandbox settings +type GeneralConfig struct { + Enabled bool `yaml:"enabled"` + 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{ + Enabled: true, + 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/anvillm", + "{HOME}/.claude.json", + }, + RWX: []string{}, + }, + Network: NetworkConfig{ + Enabled: false, + Unrestricted: true, // Unrestricted network (fine-grained restrictions require Landlock v5+) + BindTCP: []string{}, + ConnectTCP: []string{"443"}, // Example: HTTPS for API calls (when Enabled: true) + }, + Advanced: AdvancedConfig{ + LDD: false, // Disabled: fails on Node.js scripts like claude + AddExec: true, + }, + } +} + +// ConfigPath returns the path to the config file +func ConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "anvillm", "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) { + // Create default config + cfg := DefaultConfig() + if saveErr := Save(cfg); saveErr != nil { + // Return defaults even if save fails + 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 with explanatory comments +func Save(cfg *Config) error { + path := ConfigPath() + + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + // Prepend header with documentation + header := `# AnviLLM Sandbox Configuration +# This file controls landrun sandboxing for all backend sessions +# Documentation: https://github.com/landlock-lsm/landrun +# +# Path templates: +# {CWD} - Session working directory +# {HOME} - User home directory +# {TMPDIR} - Temporary directory (/tmp or $TMPDIR) +# +# WARNING: CLI tools need network access to function! +# - Claude requires connect_tcp for claude.com +# - Kiro requires connect_tcp for kiro.ai +# +# Changes apply to NEW sessions only. Existing sessions are not affected. + +` + data = append([]byte(header), data...) + + return os.WriteFile(path, data, 0644) +} + +// expandPath replaces template variables with actual values +func expandPath(pattern, cwd, home, tmpdir string) string { + s := pattern + s = strings.ReplaceAll(s, "{CWD}", cwd) + s = strings.ReplaceAll(s, "{HOME}", home) + s = strings.ReplaceAll(s, "{TMPDIR}", tmpdir) + return s +} 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..7dfd01e --- /dev/null +++ b/internal/sandbox/wrapper.go @@ -0,0 +1,158 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" +) + +// WrapCommand wraps a command with landrun based on the configuration. +// Returns the wrapped command args, or the original command if sandboxing is disabled. +func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string { + if !cfg.General.Enabled { + return originalCmd + } + + if !IsAvailable() { + if cfg.General.BestEffort { + // Silently fall back to unsandboxed execution + return originalCmd + } + // Strict mode: would fail, but caller should check IsAvailable() first + return originalCmd + } + + home, _ := os.UserHomeDir() + tmpdir := os.Getenv("TMPDIR") + if tmpdir == "" { + tmpdir = "/tmp" + } + + args := []string{"landrun"} + + // Log level + if cfg.General.LogLevel != "" { + args = append(args, "--log-level", cfg.General.LogLevel) + } + + // Best effort mode + if cfg.General.BestEffort { + args = append(args, "--best-effort") + } + + // Advanced options + if cfg.Advanced.LDD { + args = append(args, "--ldd") + } + if cfg.Advanced.AddExec { + args = append(args, "--add-exec") + } + + // Filesystem permissions - RO (read-only, no execute) + for _, path := range cfg.Filesystem.RO { + expanded := expandPath(path, cwd, home, tmpdir) + if pathExists(expanded) { + args = append(args, "--ro", expanded) + } + } + + // Filesystem permissions - ROX (read-only with execute) + for _, path := range cfg.Filesystem.ROX { + expanded := expandPath(path, cwd, home, tmpdir) + if pathExists(expanded) { + args = append(args, "--rox", expanded) + } + } + + // Filesystem permissions - RW (read-write, no execute) + for _, path := range cfg.Filesystem.RW { + expanded := expandPath(path, cwd, home, tmpdir) + if pathExists(expanded) { + args = append(args, "--rw", expanded) + } + } + + // Filesystem permissions - RWX (read-write with execute) + for _, path := range cfg.Filesystem.RWX { + expanded := expandPath(path, cwd, home, tmpdir) + if pathExists(expanded) { + args = append(args, "--rwx", expanded) + } + } + + // Network permissions + 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) + } + } + // If network not enabled and not unrestricted, no network flags = no network access + + // Append original command after -- + args = append(args, "--") + args = append(args, originalCmd...) + + return args +} + +// BuildSummary creates a human-readable summary of sandbox settings +func BuildSummary(cfg *Config) string { + var lines []string + + if !cfg.General.Enabled { + return "Sandboxing: DISABLED" + } + + lines = append(lines, "Sandboxing: ENABLED") + + if cfg.General.BestEffort { + lines = append(lines, " Mode: Best-effort (fallback if landrun unavailable)") + } else { + lines = append(lines, " Mode: Strict (require landrun)") + } + + // Filesystem + lines = append(lines, "") + lines = append(lines, "Filesystem:") + if len(cfg.Filesystem.RW) > 0 { + lines = append(lines, fmt.Sprintf(" Read-Write: %s", strings.Join(cfg.Filesystem.RW, ", "))) + } + if len(cfg.Filesystem.RO) > 0 { + lines = append(lines, fmt.Sprintf(" Read-Only: %s", strings.Join(cfg.Filesystem.RO, ", "))) + } + if len(cfg.Filesystem.ROX) > 0 { + lines = append(lines, fmt.Sprintf(" Read-Execute: %s", strings.Join(cfg.Filesystem.ROX, ", "))) + } + if len(cfg.Filesystem.RWX) > 0 { + lines = append(lines, fmt.Sprintf(" Read-Write-Execute: %s", strings.Join(cfg.Filesystem.RWX, ", "))) + } + + // Network + lines = append(lines, "") + if cfg.Network.Unrestricted { + lines = append(lines, "Network: UNRESTRICTED") + } else if cfg.Network.Enabled { + lines = append(lines, "Network: LIMITED") + if len(cfg.Network.ConnectTCP) > 0 { + lines = append(lines, fmt.Sprintf(" Connect TCP: %s", strings.Join(cfg.Network.ConnectTCP, ", "))) + } + if len(cfg.Network.BindTCP) > 0 { + lines = append(lines, fmt.Sprintf(" Bind TCP: %s", strings.Join(cfg.Network.BindTCP, ", "))) + } + } else { + lines = append(lines, "Network: BLOCKED") + } + + return strings.Join(lines, "\n") +} + +// pathExists checks if a file or directory exists +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/ui/sandbox.go b/internal/ui/sandbox.go new file mode 100644 index 0000000..9b9986c --- /dev/null +++ b/internal/ui/sandbox.go @@ -0,0 +1,150 @@ +package ui + +import ( + "anvillm/internal/sandbox" + "fmt" + "os" + "os/exec" + "strings" + + "9fans.net/go/acme" +) + +// OpenSandboxWindow opens the sandbox configuration window +func OpenSandboxWindow() error { + w, err := acme.New() + if err != nil { + return err + } + + w.Name("/AnviLLM/Sandbox") + w.Write("tag", []byte("Get Edit Reload Status ")) + + go handleSandboxWindow(w) + return nil +} + +func handleSandboxWindow(w *acme.Win) { + defer w.CloseFiles() + + // Load and display current config + refreshSandboxWindow(w) + + for e := range w.EventChan() { + switch e.C2 { + case 'x', 'X': + cmd := string(e.Text) + + switch cmd { + case "Get": + refreshSandboxWindow(w) + case "Edit": + openConfigInEditor() + case "Reload": + // Config is edited externally, just reload + refreshSandboxWindow(w) + fmt.Println("Reloaded config from disk. Changes apply to new sessions only.") + case "Status": + showStatus(w) + default: + w.WriteEvent(e) + } + default: + w.WriteEvent(e) + } + } +} + +func refreshSandboxWindow(w *acme.Win) { + cfg, err := sandbox.Load() + if err != nil { + w.Addr(",") + w.Write("data", []byte(fmt.Sprintf("Error loading config: %v\n", err))) + w.Ctl("clean") + return + } + + var buf strings.Builder + + buf.WriteString("AnviLLM Sandbox Configuration\n") + buf.WriteString(strings.Repeat("=", 60) + "\n\n") + + // Summary + buf.WriteString(sandbox.BuildSummary(cfg)) + buf.WriteString("\n\n") + + // Instructions + buf.WriteString(strings.Repeat("-", 60) + "\n") + buf.WriteString("Commands:\n") + buf.WriteString(" Edit - Open config file in $EDITOR or Acme\n") + buf.WriteString(" Reload - Reload config from disk\n") + buf.WriteString(" Status - Check landrun availability\n") + buf.WriteString(" Get - Refresh this window\n\n") + + buf.WriteString("Config file: " + sandbox.ConfigPath() + "\n") + buf.WriteString("Note: Changes apply to NEW sessions only.\n") + buf.WriteString(" Active sessions are NOT affected.\n") + + w.Addr(",") + w.Write("data", []byte(buf.String())) + w.Ctl("clean") +} + +func openConfigInEditor() { + path := sandbox.ConfigPath() + + // Try to open in Acme via 9p + cmd := exec.Command("9p", "write", "acme/new/ctl") + stdin, err := cmd.StdinPipe() + if err == nil { + cmd.Start() + fmt.Fprintf(stdin, "name %s\n", path) + fmt.Fprintf(stdin, "get\n") + stdin.Close() + cmd.Wait() + return + } + + // Fallback: use environment EDITOR + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + cmd = exec.Command(editor, path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Run() +} + +func showStatus(w *acme.Win) { + var buf strings.Builder + + buf.WriteString("\n") + buf.WriteString(strings.Repeat("-", 60) + "\n") + buf.WriteString("Landrun Status:\n") + buf.WriteString(strings.Repeat("-", 60) + "\n") + + if sandbox.IsAvailable() { + buf.WriteString("Status: AVAILABLE\n") + version := sandbox.Version() + if version != "" { + buf.WriteString("Version: " + version + "\n") + } + if path, err := exec.LookPath("landrun"); err == nil { + buf.WriteString("Location: " + path + "\n") + } + } else { + buf.WriteString("Status: NOT AVAILABLE\n") + buf.WriteString("\n") + buf.WriteString("Install landrun:\n") + buf.WriteString(" go install github.com/landlock-lsm/landrun@latest\n") + buf.WriteString("\n") + buf.WriteString("Or download from:\n") + buf.WriteString(" https://github.com/landlock-lsm/landrun\n") + } + + w.Addr("$") + w.Write("data", []byte(buf.String())) +} diff --git a/main.go b/main.go index 7c4430b..721e1df 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "anvillm/internal/debug" "anvillm/internal/p9" "anvillm/internal/session" + "anvillm/internal/ui" "context" "flag" "fmt" @@ -85,7 +86,7 @@ func main() { defer w.CloseFiles() w.Name(windowName) - w.Write("tag", []byte("Kiro Claude Open Kill Get Login ")) + w.Write("tag", []byte("Kiro Claude Open Kill Get Sandbox Login ")) refreshList(w, mgr) w.Ctl("clean") @@ -210,6 +211,10 @@ func main() { state = "enabled" } fmt.Printf("Debug mode %s\n", state) + case "Sandbox": + if err := ui.OpenSandboxWindow(); err != nil { + fmt.Fprintf(os.Stderr, "Error opening sandbox window: %v\n", err) + } case "Login": cmd := exec.Command("firefox") cmd.Start()