basic sandboxing support with landrun, fail fast
This commit is contained in:
parent
3c66c48241
commit
528a482fe8
129
README.md
129
README.md
|
|
@ -216,6 +216,135 @@ Backends create persistent tmux sessions named `anvillm-<backend>`:
|
|||
|
||||
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
|
||||
|
|
|
|||
9
go.mod
9
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
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<dir-path>/<session-id>.jsonl
|
||||
func NewClaude() backend.Backend {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
7
main.go
7
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()
|
||||
|
|
|
|||
Reference in New Issue