diff --git a/cfg/sandbox/anvilmcp.yaml b/cfg/sandbox/anvilmcp.yaml new file mode 100644 index 0000000..983cdfe --- /dev/null +++ b/cfg/sandbox/anvilmcp.yaml @@ -0,0 +1,36 @@ +# Default sandbox config for anvilmcp execute_code +# Based on hard-coded permissions from execute.go +cwd: temp # Use temp workspace (cleaned up after execution) +filesystem: + ro: + - "/etc/alternatives" + - "/etc/ld.so.cache" + - "/etc/passwd" + - "/dev/urandom" + rox: + - "/usr" + - "/lib" + - "/lib64" + - "/bin" + - "{HOME}/go/bin" + - "{HOME}/bin" + - "{HOME}/.local/bin" + - "{HOME}/opt" + - "{PLAN9}/bin" + rw: + - "/dev/null" + - "/tmp" + - "{NAMESPACE}" + rwx: + - "{CWD}" +env: + - NAMESPACE + - PATH + - HOME + - PLAN9 + - DISPLAY + - WAYLAND_DISPLAY + - USER + - AGENT_ID +network: + unrestricted: true diff --git a/cfg/sandbox/default.yaml b/cfg/sandbox/default.yaml index 97c0a33..d0fdd2b 100644 --- a/cfg/sandbox/default.yaml +++ b/cfg/sandbox/default.yaml @@ -1,5 +1,6 @@ # Default role: combined most-permissive settings from all tasks/roles # TODO: revisit role-based permissions later +cwd: "{CWD}" filesystem: ro: - "{HOME}/.gitconfig" diff --git a/cmd/anvilmcp/anvilmcp b/cmd/anvilmcp/anvilmcp index 68e9e85..da6c6c1 100755 Binary files a/cmd/anvilmcp/anvilmcp and b/cmd/anvilmcp/anvilmcp differ diff --git a/cmd/anvilmcp/execute.go b/cmd/anvilmcp/execute.go index 7c651ff..e5bdbdd 100644 --- a/cmd/anvilmcp/execute.go +++ b/cmd/anvilmcp/execute.go @@ -12,6 +12,8 @@ import ( "strings" "sync" "time" + + "gopkg.in/yaml.v3" ) var dangerousPatterns = []*regexp.Regexp{ @@ -22,16 +24,13 @@ var dangerousPatterns = []*regexp.Regexp{ regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\.\.?(/|$)`), // rm -rf ./ or rm -rf ../ regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+~`), // rm -rf ~ (home dir) regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\*`), // rm -rf * (glob expansion) - regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&`), // fork bomb - regexp.MustCompile(`\bmkfs\b`), // filesystem format - regexp.MustCompile(`\bdd\b.*\bif=/dev/`), // dd from device - regexp.MustCompile(`\bchmod\s+777\b`), // world-writable - regexp.MustCompile(`\b(curl|wget)\s+\S*://`), // network fetch (any protocol) - regexp.MustCompile(`>\s*/dev/`), // write to device - regexp.MustCompile(`\beval\s*\(`), // eval execution - regexp.MustCompile(`\bexec\s*\(`), // exec execution - regexp.MustCompile(`\b(sudo|su)\b`), // privilege escalation - regexp.MustCompile(`/etc/(passwd|shadow|sudoers)`), // sensitive files + regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&`), // fork bomb + regexp.MustCompile(`\bmkfs\b`), // filesystem format + regexp.MustCompile(`\bdd\b.*\bif=/dev/`), // dd from device + regexp.MustCompile(`>\s*/dev/sd`), // write to block device + regexp.MustCompile(`\beval\s+".*\$`), // eval with variable expansion + regexp.MustCompile(`\b(sudo|su)\s`), // privilege escalation + regexp.MustCompile(`/etc/(shadow|sudoers)`), // sensitive files (not passwd) } var whitespacePattern = regexp.MustCompile(`\s+`) @@ -109,7 +108,95 @@ func validateCode(code string) error { return nil } -func executeCode(code, language string, timeout int) (string, error) { +// 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"` +} + +// loadSandboxConfig loads sandbox config from ~/.config/anvillm/sandbox/.yaml +// Falls back to ./cfg/sandbox/.yaml for development +func loadSandboxConfig(name string) (*SandboxConfig, error) { + // Validate name to prevent path traversal + 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) + if err != nil { + return nil, fmt.Errorf("failed to load sandbox config %s: %v", name, err) + } + + 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 +} + +// 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) { start := time.Now() if timeout <= 0 { @@ -129,113 +216,92 @@ func executeCode(code, language string, timeout int) (string, error) { return "", err } + // Load sandbox config + cfg, err := loadSandboxConfig(sandbox) + if err != nil { + return "", err + } + homeDir, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("failed to get home dir: %v", err) } + + // Determine working directory based on config + var workDir string + var cleanupWorkDir bool - 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) + if cfg.CWD == "" || cfg.CWD == "temp" { + // Use temp workspace (original behavior) + 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) + } + workDir, err = os.MkdirTemp(workspaceBase, "anvilmcp-*") + if err != nil { + 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 } - workDir, err := os.MkdirTemp(workspaceBase, "anvilmcp-*") - if err != nil { - return "", fmt.Errorf("failed to create workspace: %v", err) + if cleanupWorkDir { + defer os.RemoveAll(workDir) } - defer os.RemoveAll(workDir) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() - // Check for landrun landrunPath, err := exec.LookPath("landrun") if err != nil { return "", fmt.Errorf("landrun not found: %v", err) } - // Get namespace for 9p access - namespace := os.Getenv("NAMESPACE") - if namespace == "" { - // Try to get from namespace command - if nsCmd, err := exec.Command("namespace").Output(); err == nil { - namespace = strings.TrimSpace(string(nsCmd)) - } - if namespace == "" { - namespace = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) - } - } - - // Get AGENT_ID if set - agentID := os.Getenv("AGENT_ID") - var cmd *exec.Cmd switch language { case "bash", "": - args := []string{ - "--rwx", workDir, - "--unrestricted-network", - } - - // Add common system paths if they exist - systemPaths := []struct { - flag string - path string - }{ - {"--rox", "/usr"}, - {"--rox", "/lib"}, - {"--rox", "/lib64"}, - {"--rox", "/bin"}, - {"--ro", "/etc/alternatives"}, - {"--ro", "/etc/ld.so.cache"}, - {"--ro", "/etc/passwd"}, - {"--rw", "/dev/null"}, - {"--ro", "/dev/urandom"}, - {"--rw", "/tmp"}, - } - - for _, sp := range systemPaths { - if _, err := os.Stat(sp.path); err == nil { - args = append(args, sp.flag, sp.path) + args := []string{} + + // 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) + } + if _, err := os.Stat(expanded); err == nil { + args = append(args, flag, expanded) + } } } - - // Add namespace if set - if namespace != "" { - args = append(args, "--rw", namespace) + + addPaths("--ro", cfg.Filesystem.RO) + addPaths("--rox", cfg.Filesystem.ROX) + addPaths("--rw", cfg.Filesystem.RW) + addPaths("--rwx", cfg.Filesystem.RWX) + + // Network + if cfg.Network.Unrestricted { + args = append(args, "--unrestricted-network") } - - // Add common user binary directories - userBinDirs := []string{ - filepath.Join(homeDir, "bin"), - filepath.Join(homeDir, ".local", "bin"), - filepath.Join(homeDir, "opt"), - filepath.Join(homeDir, "go", "bin"), - } - - // Add PLAN9 bin if set - if plan9 := os.Getenv("PLAN9"); plan9 != "" { - userBinDirs = append(userBinDirs, filepath.Join(plan9, "bin")) - } - - for _, dir := range userBinDirs { - if _, err := os.Stat(dir); err == nil { - args = append(args, "--rox", dir) + + // Environment variables + for _, env := range cfg.Env { + if os.Getenv(env) != "" || env == "HOME" || env == "USER" || env == "PATH" { + args = append(args, "--env", env) } } - - args = append(args, - "--env", "NAMESPACE", - "--env", "PATH", - "--env", "HOME", - "--env", "PLAN9", - "--env", "DISPLAY", - "--env", "WAYLAND_DISPLAY", - "--env", "USER", - ) - if agentID != "" { - args = append(args, "--env", "AGENT_ID") - } + args = append(args, "--", "bash", "-c", code) cmd = exec.CommandContext(ctx, landrunPath, args...) @@ -253,7 +319,6 @@ func executeCode(code, language string, timeout int) (string, error) { err = cmd.Run() output := outputBuf.Bytes() - // Append truncation notice if output was truncated if lw.truncated { output = append(output, []byte("\n[output truncated at 10MB]")...) } diff --git a/cmd/anvilmcp/main.go b/cmd/anvilmcp/main.go index ff8e369..9234976 100644 --- a/cmd/anvilmcp/main.go +++ b/cmd/anvilmcp/main.go @@ -95,6 +95,7 @@ func main() { "code": {Type: "string", Description: "Bash code to execute"}, "language": {Type: "string", Description: "Programming language (bash)", Enum: []string{"bash"}}, "timeout": {Type: "integer", Description: "Timeout in seconds (default: 30)"}, + "sandbox": {Type: "string", Description: "Sandbox config name (default: default)"}, }, Required: []string{"code"}, }, @@ -132,13 +133,17 @@ func handleToolCall(req MCPRequest) { if t, ok := params.Arguments["timeout"].(float64); ok { timeout = int(t) } + sandbox, _ := params.Arguments["sandbox"].(string) + if sandbox == "" { + sandbox = "anvilmcp" + } // Acquire execution slot executionSemaphore <- struct{}{} defer func() { <-executionSemaphore }() - fmt.Fprintf(os.Stderr, "[anvilmcp] Executing bash code (timeout: %ds)\n", timeout) - result, err := executeCode(code, language, timeout) + fmt.Fprintf(os.Stderr, "[anvilmcp] Executing bash code (timeout: %ds, sandbox: %s)\n", timeout, sandbox) + result, err := executeCode(code, language, timeout, sandbox) // Log token comparison codeTokens := estimateTokens(code) diff --git a/skills/anvillm-communication/SKILL.md b/skills/anvillm-communication/SKILL.md index c51588b..ee33094 100644 --- a/skills/anvillm-communication/SKILL.md +++ b/skills/anvillm-communication/SKILL.md @@ -9,12 +9,28 @@ when_to_load: Load when you need to send messages to other agents, respond to th ## Tools -**IMPORTANT:** All anvillm-communication tools must be run via `execute_code` tool. **IMPORTANT:** Read the $AGENT_ID environment variable to get your agent ID -- Discover agents: `bash <(9p read agent/tools/agents/list_sessions.sh) | grep "$(pwd)"` -- Send message: `bash <(9p read agent/tools/messaging/send_message.sh) ` -- Check inbox: `bash <(9p read agent/tools/messaging/read_inbox.sh) ` +Discover agents: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/agents/list_sessions.sh) | grep "$(pwd)" +``` + +Send message: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/messaging/send_message.sh) +``` + +Check inbox: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/messaging/read_inbox.sh) +``` ## Rules diff --git a/skills/anvillm-sessions/SKILL.md b/skills/anvillm-sessions/SKILL.md index 209e78a..76cb2d0 100644 --- a/skills/anvillm-sessions/SKILL.md +++ b/skills/anvillm-sessions/SKILL.md @@ -10,13 +10,42 @@ Create and control agent sessions. ## Commands -All commands must be run via `execute_code` tool, EXCEPT spawn_agent.sh which must NOT use execute_code: +All commands use `execute_code` with `sandbox: "anvilmcp"`, EXCEPT spawn_agent.sh which requires `sandbox: "default"`. -- List sessions: `list_sessions.sh` -- Create session: `create_session.sh [sandbox=] [model=]` -- Control session: `control_session.sh ` -- Spawn agent: `spawn_agent.sh [cwd-path] [initial-context-prompt]` (do NOT use execute_code) -- Kill agent: `kill_agent.sh [cwd-path]` +List sessions: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/agents/list_sessions.sh) +``` + +Create session: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/agents/create_session.sh) [sandbox=] [model=] +``` + +Control session: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/agents/control_session.sh) +``` + +Spawn agent: +``` +Tool: execute_code +sandbox: default +code: bash <(9p read agent/tools/agents/spawn_agent.sh) [cwd-path] [initial-context-prompt] +``` + +Kill agent: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/agents/kill_agent.sh) [cwd-path] +``` ## Backends diff --git a/skills/beads/SKILL.md b/skills/beads/SKILL.md index 4760526..2e8432f 100644 --- a/skills/beads/SKILL.md +++ b/skills/beads/SKILL.md @@ -8,33 +8,123 @@ description: Manage tasks using the beads 9P interface. Use when creating, updat ## Project Mount Discovery -1. Find your project's beads mount: `MOUNT=$(bash <(9p read agent/tools/beads/list_mounts.sh) | grep "$(pwd)" | awk '{print $1}')` -2. If no mount exists, create one and use it: `bash <(9p read agent/tools/beads/mount_beads.sh) "$(pwd)"`. The mount will be named for the base directory of $(pwd). +Find your project's beads mount: +``` +Tool: execute_code +sandbox: anvilmcp +code: MOUNT=$(bash <(9p read agent/tools/beads/list_mounts.sh) | grep "$(pwd)" | awk '{print $1}'); echo $MOUNT +``` + +If no mount exists, create one: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/beads/mount_beads.sh) "$(pwd)" +``` ## Tools -**IMPORTANT:** All beads tools listed must be run via `execute_code` tool. +All tools require `` as first argument. Exceptions: `list_mounts.sh`, `mount_beads.sh`, `umount_beads.sh`. -**IMPORTANT:** All tools require `` as first argument. Exceptions: `list_mounts.sh`, `mount_beads.sh`, `umount_beads.sh`. +List mounts: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/beads/list_mounts.sh) +``` -- List mounts: `bash <(9p read tools/beads/list_mounts.sh)` -- List ready beads: `bash <(9p read tools/beads/list_ready_beads.sh) ` -- List all beads: `bash <(9p read tools/beads/list_beads.sh) ` -- Read bead: `bash <(9p read tools/beads/read_bead.sh) ` -- Read bead JSON: `bash <(9p read tools/beads/read_bead.sh) json` -- Read bead comments: `bash <(9p read tools/beads/read_bead.sh) comments` +List ready beads: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/beads/list_ready_beads.sh) +``` + +List all beads: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/beads/list_beads.sh) +``` + +Read bead JSON: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/beads/read_bead.sh) json +``` + +Read bead comments: +``` +Tool: execute_code +sandbox: anvilmcp +code: bash <(9p read agent/tools/beads/read_bead.sh) comments +``` **IMPORTANT:** When working on a bead, always check `comment_count` in the JSON output. If > 0, read comments for additional context, decisions, or blockers. -- Claim bead: `bash <(9p read tools/beads/claim_bead.sh) bd-abc [$AGENT_ID]` -- Complete bead: `bash <(9p read tools/beads/complete_bead.sh) bd-abc` -- Fail bead: `bash <(9p read tools/beads/fail_bead.sh) bd-abc "reason"` -- Create bead: `bash <(9p read tools/beads/create_bead.sh) "title" "desc" [parent]` -- Update bead: `bash <(9p read tools/beads/update_bead.sh) bd-abc ` -- Add dependency: `bash <(9p read tools/beads/add_dependency.sh) ` -- Comment on bead: `bash <(9p read tools/beads/comment_bead.sh) bd-abc "text"` -- Label bead: `bash <(9p read tools/beads/label_bead.sh) bd-abc