package devicemesh import ( "fmt" "strings" ) // tools_builtin.go: declarative catalog of the standard tools an LLM agent // gets when its config enables device_mesh. The list mirrors issue 0144 §2.1. // // Each ToolSpec is pure data: descriptions for the LLM, JSON-Schema-lite for // validation, and pure ArgMapping / ResultMapping functions. No I/O. // // Mode "user" registers the tools allowed for the unprivileged agent (uid // lucas in home-wsl). Mode "sudo" registers tools whose underlying // capability requires_approval: true on the device_agent side. The // separation is physical, not just RBAC — the user-agent process literally // never sees pkg.install in its registry, so prompt injection cannot // surface it (issue 0144 §1.2). // RegistrationMode controls which subset of the built-in catalog is // registered. "user" gets non-approval tools. "sudo" gets only the approval // gated tools. "all" gets everything (mainly for tests and tooling). type RegistrationMode string const ( ModeUser RegistrationMode = "user" ModeSudo RegistrationMode = "sudo" ModeAll RegistrationMode = "all" ) // RegisterBuiltins registers the standard catalog of devicemesh tools into // the given registry, filtered by the requested mode. // // Returns the list of registered tool names so callers can log it. // // shell.eval is a special case: it is always registered in BOTH ModeUser and // ModeSudo, but the sudo variant is rewritten via withApprovalRequired so the // LLM sees RequiresApproval=true. The real guardrail (blocklist + // auto-approve patterns + operator approval) lives in the device_agent — the // flag here is metadata that drives RBAC at the device_mesh edge. func RegisterBuiltins(reg *ToolRegistry, mode RegistrationMode) []string { if reg == nil { return nil } all := builtinSpecs() registered := make([]string, 0, len(all)) for _, spec := range all { switch mode { case ModeUser: if spec.RequiresApproval { continue } case ModeSudo: // In sudo mode, force RequiresApproval=true on shell.eval so the // metadata exposed to the LLM matches the device manifest. Other // non-approval tools are skipped (sudo agents only see approval // gated tools). if spec.Name == "shell.eval" { spec = withApprovalRequired(spec) } else if !spec.RequiresApproval { continue } case ModeAll: // fallthrough — accept everything default: // Unknown mode: behave like "user" (safer default). if spec.RequiresApproval { continue } } reg.Register(spec) registered = append(registered, spec.Name) } return registered } // withApprovalRequired returns a clone of spec with RequiresApproval set to // true. Used to upgrade a tool that defaults to "no approval" (user scope) // into its sudo equivalent without mutating the original spec returned by // builtinSpecs(). Pure function — no side effects. func withApprovalRequired(spec ToolSpec) ToolSpec { spec.RequiresApproval = true return spec } // builtinSpecs returns the full catalog (both user and sudo). The split into // scopes happens in RegisterBuiltins. Defined as a function so future // builders can compose this with host-specific overrides. func builtinSpecs() []ToolSpec { return []ToolSpec{ execSpec(), shellEvalSpec(), fsReadSpec(), fsWriteSpec(), fsListSpec(), fsStatSpec(), gitCloneSpec(), gitCommitSpec(), gitPushSpec(), pkgInstallSpec(), pkgSearchSpec(), procListSpec(), procKillSpec(), dockerListSpec(), dockerExecSpec(), dockerLogsSpec(), } } // ----- exec ----- func execSpec() ToolSpec { return ToolSpec{ Name: "exec", Description: "Execute a command on the remote device. argv is parsed as exec.Command (NO shell). " + "Returns stdout, stderr, exit_code, duration_ms. Use this for: listing files, running scripts, " + "invoking CLIs already installed. Do NOT use this for shell redirection, pipes, or globs.", Capability: "shell.exec", InputSchema: map[string]any{ "type": "object", "required": []string{"argv"}, "additionalProperties": false, "properties": map[string]any{ "argv": map[string]any{ "type": "array", "items": map[string]any{"type": "string"}, }, "cwd": map[string]any{"type": "string"}, "timeout_s": map[string]any{"type": "integer"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { argv, err := requireStringSlice(input, "argv") if err != nil { return nil, err } if len(argv) == 0 { return nil, fmt.Errorf("argv must not be empty") } out := map[string]any{"argv": argv} if cwd, ok := input["cwd"].(string); ok && cwd != "" { out["cwd"] = cwd } if timeout, ok := input["timeout_s"]; ok { out["timeout_s"] = toInt(timeout, 30) } return out, nil }, ResultMapping: func(result map[string]any) (any, error) { // Pass through but normalize: ensure exit_code is int. if result == nil { return map[string]any{ "stdout": "", "stderr": "", "exit_code": 0, }, nil } out := map[string]any{ "stdout": getString(result, "stdout"), "stderr": getString(result, "stderr"), "exit_code": toInt(result["exit_code"], 0), } if dur, ok := result["duration_ms"]; ok { out["duration_ms"] = toInt(dur, 0) } return out, nil }, } } // ----- shell.eval ----- // shellEvalSpec is the "powerful tool": a free-form shell command evaluator. // Unlike exec (positional argv, no shell), shell.eval accepts a single string // passed verbatim to bash or powershell on the device. // // Its existence is justified because no structured tool can cover every legal // shell idiom (pipes, redirects, here-docs, $() expansions, complex globs). // Without it the LLM resorts to multi-step exec chains and loses fidelity. // // Safety: this tool's RequiresApproval default is false in ModeUser. The real // guardrails live device-side: // // - Hardcoded blocklist (rm -rf /, dd, mkfs, fork-bombs, shutdown, ...) // always rejects regardless of agent or operator. // - Auto-approve whitelist ('^git ', '^ls ', '^cat ', ...) bypasses the // operator and executes directly. // - Anything else returns approval_status='queued' and waits for the // operator to confirm in #operator-approvals. // // For sudo agents, RegisterBuiltins promotes RequiresApproval=true via // withApprovalRequired so the LLM-facing metadata matches the device manifest. func shellEvalSpec() ToolSpec { return ToolSpec{ Name: "shell.eval", Description: "Evaluate a free-form shell command on the device. Auto-detects bash (Linux/WSL) or powershell (Windows). " + "Hardcoded safety blocklist applies (rm -rf /, dd, mkfs, fork-bombs, shutdown, etc.) — these always reject. " + "Auto-approve patterns ('^git ', '^ls ', '^cat ', etc.) execute directly. Other commands may require operator " + "approval (returns approval_status='queued' and the operator must confirm in Element).", Capability: "shell.eval", // RequiresApproval is false here so user mode picks it up. Sudo mode // rewrites this via withApprovalRequired in RegisterBuiltins. RequiresApproval: false, InputSchema: map[string]any{ "type": "object", "required": []string{"cmd"}, "additionalProperties": false, "properties": map[string]any{ "cmd": map[string]any{ "type": "string", "description": "Shell command string. Bash or PowerShell syntax depending on device OS.", "minLength": 1, }, "shell": map[string]any{ "type": "string", "enum": []any{"auto", "bash", "powershell"}, "description": "Force shell. 'auto' (default) picks by device OS.", }, "cwd": map[string]any{ "type": "string", "description": "Optional absolute path to run from.", }, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { cmd, err := requireString(input, "cmd") if err != nil { return nil, err } if cmd == "" { return nil, fmt.Errorf("cmd must not be empty") } out := map[string]any{"cmd": cmd} if s, ok := input["shell"].(string); ok && s != "" { out["shell"] = s } if c, ok := input["cwd"].(string); ok && c != "" { out["cwd"] = c } return out, nil }, ResultMapping: func(result map[string]any) (any, error) { // Pass result through — the LLM sees fields like stdout, stderr, // exit_code, approval_status, cmd_executed, truncated, duration_ms // as the device_agent returns them. No normalization here because // the device contract is richer than exec (approval_status etc.) // and we do not want to drop fields the device may add later. if result == nil { return map[string]any{}, nil } return result, nil }, } } // ----- fs.read ----- func fsReadSpec() ToolSpec { return ToolSpec{ Name: "fs.read", Description: "Read a file on the remote device. Returns content_b64 (base64) or content (utf8), " + "size, mtime. Use max_bytes to cap large files.", Capability: "fs.read", InputSchema: map[string]any{ "type": "object", "required": []string{"path"}, "additionalProperties": false, "properties": map[string]any{ "path": map[string]any{"type": "string"}, "max_bytes": map[string]any{"type": "integer"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { path, err := requireString(input, "path") if err != nil { return nil, err } out := map[string]any{"path": path} if mb, ok := input["max_bytes"]; ok { out["max_bytes"] = toInt(mb, 0) } return out, nil }, ResultMapping: passthrough, } } // ----- fs.write ----- func fsWriteSpec() ToolSpec { return ToolSpec{ Name: "fs.write", Description: "Write a file on the remote device. Creates parent dirs if missing. Overwrites if " + "the file exists. Use content_b64 for binary; use content for utf8. Optional mode (octal int).", Capability: "fs.write", // fs.write to system paths requires_approval is enforced device-side by // the manifest. The tool itself is registered for both modes. InputSchema: map[string]any{ "type": "object", "required": []string{"path"}, "additionalProperties": false, "properties": map[string]any{ "path": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}, "content_b64": map[string]any{"type": "string"}, "mode": map[string]any{"type": "integer"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { path, err := requireString(input, "path") if err != nil { return nil, err } content, hasContent := input["content"].(string) contentB64, hasB64 := input["content_b64"].(string) if !hasContent && !hasB64 { return nil, fmt.Errorf("fs.write requires content or content_b64") } out := map[string]any{"path": path} if hasContent { out["content"] = content } if hasB64 { out["content_b64"] = contentB64 } if mode, ok := input["mode"]; ok { out["mode"] = toInt(mode, 0) } return out, nil }, ResultMapping: passthrough, } } // ----- fs.list ----- func fsListSpec() ToolSpec { return ToolSpec{ Name: "fs.list", Description: "List a directory on the remote device. Returns entries: [{name, kind, size, mtime}]. Optional glob filter.", Capability: "fs.list", InputSchema: map[string]any{ "type": "object", "required": []string{"dir"}, "additionalProperties": false, "properties": map[string]any{ "dir": map[string]any{"type": "string"}, "glob": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { dir, err := requireString(input, "dir") if err != nil { return nil, err } out := map[string]any{"dir": dir} if glob, ok := input["glob"].(string); ok && glob != "" { out["glob"] = glob } return out, nil }, ResultMapping: passthrough, } } // ----- fs.stat ----- func fsStatSpec() ToolSpec { return ToolSpec{ Name: "fs.stat", Description: "Stat a file or dir on the remote device. Returns kind, size, mtime, mode.", Capability: "fs.stat", InputSchema: map[string]any{ "type": "object", "required": []string{"path"}, "additionalProperties": false, "properties": map[string]any{ "path": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { path, err := requireString(input, "path") if err != nil { return nil, err } return map[string]any{"path": path}, nil }, ResultMapping: passthrough, } } // ----- git.clone ----- func gitCloneSpec() ToolSpec { return ToolSpec{ Name: "git.clone", Description: "Clone a git repository on the remote device. Returns commit_sha and branch.", Capability: "git.clone", InputSchema: map[string]any{ "type": "object", "required": []string{"url", "dest"}, "additionalProperties": false, "properties": map[string]any{ "url": map[string]any{"type": "string"}, "dest": map[string]any{"type": "string"}, "branch": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { url, err := requireString(input, "url") if err != nil { return nil, err } dest, err := requireString(input, "dest") if err != nil { return nil, err } out := map[string]any{"url": url, "dest": dest} if branch, ok := input["branch"].(string); ok && branch != "" { out["branch"] = branch } return out, nil }, ResultMapping: passthrough, } } // ----- git.commit ----- func gitCommitSpec() ToolSpec { return ToolSpec{ Name: "git.commit", Description: "Stage and commit changes in a repo on the remote device. Stages all changes by " + "default; pass files: [\"a\",\"b\"] to stage a subset. Returns commit_sha.", Capability: "git.commit", InputSchema: map[string]any{ "type": "object", "required": []string{"repo", "message"}, "additionalProperties": false, "properties": map[string]any{ "repo": map[string]any{"type": "string"}, "message": map[string]any{"type": "string"}, "files": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { repo, err := requireString(input, "repo") if err != nil { return nil, err } msg, err := requireString(input, "message") if err != nil { return nil, err } out := map[string]any{"repo": repo, "message": msg} if files, ok := input["files"]; ok { if slice, e := asStringSliceLoose(files); e == nil && len(slice) > 0 { out["files"] = slice } } return out, nil }, ResultMapping: passthrough, } } // ----- git.push ----- func gitPushSpec() ToolSpec { return ToolSpec{ Name: "git.push", Description: "Push the current branch of a repo. Optional remote (default origin) and branch (default current).", Capability: "git.push", InputSchema: map[string]any{ "type": "object", "required": []string{"repo"}, "additionalProperties": false, "properties": map[string]any{ "repo": map[string]any{"type": "string"}, "remote": map[string]any{"type": "string"}, "branch": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { repo, err := requireString(input, "repo") if err != nil { return nil, err } out := map[string]any{"repo": repo} if r, ok := input["remote"].(string); ok && r != "" { out["remote"] = r } if b, ok := input["branch"].(string); ok && b != "" { out["branch"] = b } return out, nil }, ResultMapping: passthrough, } } // ----- pkg.install ----- func pkgInstallSpec() ToolSpec { return ToolSpec{ Name: "pkg.install", Description: "Install an OS package (apt/dnf/pacman depending on host). Requires approval — the " + "operator must accept the action in #operator-approvals before it executes.", Capability: "pkg.install", RequiresApproval: true, InputSchema: map[string]any{ "type": "object", "required": []string{"name"}, "additionalProperties": false, "properties": map[string]any{ "name": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { name, err := requireString(input, "name") if err != nil { return nil, err } return map[string]any{"name": name}, nil }, ResultMapping: passthrough, } } // ----- pkg.search ----- func pkgSearchSpec() ToolSpec { return ToolSpec{ Name: "pkg.search", Description: "Search the OS package cache. No install. Returns matching packages.", Capability: "pkg.search", InputSchema: map[string]any{ "type": "object", "required": []string{"query"}, "additionalProperties": false, "properties": map[string]any{ "query": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { q, err := requireString(input, "query") if err != nil { return nil, err } return map[string]any{"query": q}, nil }, ResultMapping: passthrough, } } // ----- proc.list ----- func procListSpec() ToolSpec { return ToolSpec{ Name: "proc.list", Description: "List processes on the remote device. Optional filters: user, name_like.", Capability: "proc.list", InputSchema: map[string]any{ "type": "object", "additionalProperties": false, "properties": map[string]any{ "user": map[string]any{"type": "string"}, "name_like": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { out := map[string]any{} if u, ok := input["user"].(string); ok && u != "" { out["user"] = u } if n, ok := input["name_like"].(string); ok && n != "" { out["name_like"] = n } return out, nil }, ResultMapping: passthrough, } } // ----- proc.kill ----- func procKillSpec() ToolSpec { return ToolSpec{ Name: "proc.kill", Description: "Send a signal to a process. Signal default TERM. Killing destructive signals on " + "processes owned by another uid requires approval.", Capability: "proc.kill", RequiresApproval: true, InputSchema: map[string]any{ "type": "object", "required": []string{"pid"}, "additionalProperties": false, "properties": map[string]any{ "pid": map[string]any{"type": "integer"}, "signal": map[string]any{"type": "string"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { pidRaw, ok := input["pid"] if !ok { return nil, fmt.Errorf("proc.kill: pid is required") } out := map[string]any{"pid": toInt(pidRaw, 0)} if sig, ok := input["signal"].(string); ok && sig != "" { out["signal"] = strings.ToUpper(sig) } return out, nil }, ResultMapping: passthrough, } } // ----- docker.list ----- func dockerListSpec() ToolSpec { return ToolSpec{ Name: "docker.list", Description: "List Docker containers on the remote device. Pass all=true to include stopped.", Capability: "docker.container.list", InputSchema: map[string]any{ "type": "object", "additionalProperties": false, "properties": map[string]any{ "all": map[string]any{"type": "boolean"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { out := map[string]any{} if all, ok := input["all"].(bool); ok { out["all"] = all } return out, nil }, ResultMapping: passthrough, } } // ----- docker.exec ----- func dockerExecSpec() ToolSpec { return ToolSpec{ Name: "docker.exec", Description: "Exec a command in a Docker container. argv is a string list (no shell).", Capability: "docker.container.exec", InputSchema: map[string]any{ "type": "object", "required": []string{"container", "argv"}, "additionalProperties": false, "properties": map[string]any{ "container": map[string]any{"type": "string"}, "argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { container, err := requireString(input, "container") if err != nil { return nil, err } argv, err := requireStringSlice(input, "argv") if err != nil { return nil, err } if len(argv) == 0 { return nil, fmt.Errorf("argv must not be empty") } return map[string]any{"container": container, "argv": argv}, nil }, ResultMapping: passthrough, } } // ----- docker.logs ----- func dockerLogsSpec() ToolSpec { return ToolSpec{ Name: "docker.logs", Description: "Read the last N lines of a Docker container's logs.", Capability: "docker.container.logs", InputSchema: map[string]any{ "type": "object", "required": []string{"container"}, "additionalProperties": false, "properties": map[string]any{ "container": map[string]any{"type": "string"}, "tail": map[string]any{"type": "integer"}, }, }, ArgMapping: func(input map[string]any) (map[string]any, error) { container, err := requireString(input, "container") if err != nil { return nil, err } out := map[string]any{"container": container} if t, ok := input["tail"]; ok { out["tail"] = toInt(t, 100) } return out, nil }, ResultMapping: passthrough, } } // ----- helpers ----- func passthrough(result map[string]any) (any, error) { return result, nil } func requireString(input map[string]any, key string) (string, error) { v, ok := input[key] if !ok || v == nil { return "", fmt.Errorf("%s is required", key) } s, ok := v.(string) if !ok { return "", fmt.Errorf("%s must be a string, got %T", key, v) } return s, nil } func requireStringSlice(input map[string]any, key string) ([]string, error) { v, ok := input[key] if !ok || v == nil { return nil, fmt.Errorf("%s is required", key) } return asStringSliceLoose(v) } func asStringSliceLoose(v any) ([]string, error) { switch s := v.(type) { case []string: out := make([]string, len(s)) copy(out, s) return out, nil case []any: out := make([]string, 0, len(s)) for i, e := range s { str, ok := e.(string) if !ok { return nil, fmt.Errorf("index %d: expected string, got %T", i, e) } out = append(out, str) } return out, nil } return nil, fmt.Errorf("expected array of strings, got %T", v) } func getString(m map[string]any, key string) string { if m == nil { return "" } s, _ := m[key].(string) return s } func toInt(v any, def int) int { switch n := v.(type) { case int: return n case int32: return int(n) case int64: return int(n) case float32: return int(n) case float64: return int(n) } return def }