anvilmcp: use layered sandbox config from sandbox package

Reuse the same layered config approach as anvilsrv:
- global.yaml provides base permissions (including /usr, /lib, /bin)
- sandbox/<name>.yaml adds sandbox-specific permissions
- Merge uses most-permissive-wins strategy

This fixes the fallback to default sandbox, which now has proper
system paths from global.yaml.
This commit is contained in:
Levi Neely 2026-03-06 10:18:31 +01:00
parent 2046bc2513
commit b21c56b960
3 changed files with 49 additions and 108 deletions

View File

@ -13,7 +13,7 @@ import (
"sync"
"time"
"gopkg.in/yaml.v3"
"anvillm/internal/sandbox"
)
var dangerousPatterns = []*regexp.Regexp{
@ -117,95 +117,47 @@ func validateCode(code string) error {
return nil
}
// SandboxConfig represents the sandbox configuration loaded from YAML
type SandboxConfig struct {
CWD string `yaml:"cwd"` // "temp" for temp workspace, or path template like "{CWD}"
Filesystem struct {
RO []string `yaml:"ro"`
ROX []string `yaml:"rox"`
RW []string `yaml:"rw"`
RWX []string `yaml:"rwx"`
} `yaml:"filesystem"`
Env []string `yaml:"env"`
Network struct {
Unrestricted bool `yaml:"unrestricted"`
} `yaml:"network"`
}
// loadLayeredConfig loads sandbox config using the layered approach:
// global.yaml -> backend (anvilmcp) -> sandbox/<name>.yaml
func loadLayeredConfig(name string) (*sandbox.Config, error) {
// Load global.yaml as base
baseCfg, err := sandbox.Load()
if err != nil {
return nil, fmt.Errorf("failed to load global config: %w", err)
}
// loadSandboxConfig loads sandbox config from ~/.config/anvillm/sandbox/<name>.yaml
// Falls back to ./cfg/sandbox/<name>.yaml for development
func loadSandboxConfig(name string) (*SandboxConfig, error) {
// Validate name to prevent path traversal
// Convert base config to layered format
baseLayer := sandbox.LayeredConfig{
Filesystem: baseCfg.Filesystem,
Network: baseCfg.Network,
Env: baseCfg.Env,
}
layers := []sandbox.LayeredConfig{baseLayer}
// Load sandbox layer
if name == "" {
name = "anvilmcp"
}
for _, r := range name {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') {
return nil, fmt.Errorf("invalid sandbox name: %s", name)
}
}
homeDir, _ := os.UserHomeDir()
cfgPath := filepath.Join(homeDir, ".config", "anvillm", "sandbox", name+".yaml")
// Fall back to ./cfg/sandbox/ for development
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
cfgPath = filepath.Join("cfg", "sandbox", name+".yaml")
}
data, err := os.ReadFile(cfgPath)
sbxLayer, err := sandbox.LoadSandbox(name)
if err != nil {
return nil, fmt.Errorf("failed to load sandbox config %s: %v", name, err)
return nil, fmt.Errorf("failed to load sandbox %q: %w", name, err)
}
layers = append(layers, sbxLayer)
// Merge layers
general := sandbox.GeneralConfig{
BestEffort: baseCfg.General.BestEffort,
LogLevel: baseCfg.General.LogLevel,
}
advanced := sandbox.AdvancedConfig{
LDD: baseCfg.Advanced.LDD,
AddExec: baseCfg.Advanced.AddExec,
}
var cfg SandboxConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse sandbox config: %v", err)
}
return &cfg, nil
return sandbox.Merge(general, advanced, layers...), nil
}
// expandPath replaces template variables with actual values
func expandPath(pattern string) string {
s := pattern
homeDir, _ := os.UserHomeDir()
// Generic environment variable expansion: {VAR_NAME}
re := regexp.MustCompile(`\{([A-Z_][A-Z0-9_]*)\}`)
s = re.ReplaceAllStringFunc(s, func(match string) string {
varName := match[1 : len(match)-1]
switch varName {
case "HOME":
return homeDir
case "PLAN9":
if plan9 := os.Getenv("PLAN9"); plan9 != "" {
return plan9
}
return "" // Will cause path to be skipped
case "NAMESPACE":
if ns := os.Getenv("NAMESPACE"); ns != "" {
return ns
}
if nsCmd, err := exec.Command("namespace").Output(); err == nil {
return strings.TrimSpace(string(nsCmd))
}
return fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER"))
case "CWD":
return "" // Handled specially - replaced with workDir
}
if val := os.Getenv(varName); val != "" {
return val
}
return "" // Will cause path to be skipped
})
return s
}
func executeCode(code, language string, timeout int, sandbox string) (string, error) {
func executeCode(code, language string, timeout int, sandboxName string) (string, error) {
start := time.Now()
if timeout <= 0 {
@ -225,8 +177,8 @@ func executeCode(code, language string, timeout int, sandbox string) (string, er
return "", err
}
// Load sandbox config
cfg, err := loadSandboxConfig(sandbox)
// Load layered sandbox config
cfg, err := loadLayeredConfig(sandboxName)
if err != nil {
return "", err
}
@ -236,12 +188,12 @@ func executeCode(code, language string, timeout int, sandbox string) (string, er
return "", fmt.Errorf("failed to get home dir: %v", err)
}
// Determine working directory based on config
var workDir string
// Use current working directory
workDir, _ := os.Getwd()
// For anvilmcp sandbox, use temp workspace
var cleanupWorkDir bool
if cfg.CWD == "" || cfg.CWD == "temp" {
// Use temp workspace (original behavior)
if sandboxName == "" || sandboxName == "anvilmcp" {
workspaceBase := filepath.Join(homeDir, ".cache", "anvillm", "exec")
if err := os.MkdirAll(workspaceBase, 0700); err != nil {
return "", fmt.Errorf("failed to create workspace base: %v", err)
@ -251,13 +203,6 @@ func executeCode(code, language string, timeout int, sandbox string) (string, er
return "", fmt.Errorf("failed to create workspace: %v", err)
}
cleanupWorkDir = true
} else {
// Use configured path (e.g., "{CWD}" expands to current directory)
workDir = expandPath(cfg.CWD)
if workDir == "" {
workDir, _ = os.Getwd()
}
cleanupWorkDir = false
}
if cleanupWorkDir {
@ -280,13 +225,9 @@ func executeCode(code, language string, timeout int, sandbox string) (string, er
// Add filesystem permissions from config
addPaths := func(flag string, paths []string) {
for _, p := range paths {
expanded := expandPath(p)
if expanded == "" {
continue
}
// Handle {CWD} specially
if strings.Contains(p, "{CWD}") {
expanded = strings.ReplaceAll(p, "{CWD}", workDir)
expanded := sandbox.ExpandPath(p, workDir)
if expanded == "" || strings.Contains(expanded, "{") {
continue // Skip unexpanded templates
}
if _, err := os.Stat(expanded); err == nil {
args = append(args, flag, expanded)

View File

@ -196,8 +196,8 @@ func validateName(name string) error {
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
// CWD is special - passed as parameter, not from env

View File

@ -48,25 +48,25 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string) []string {
// Collect all filesystem paths
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"})
}