Files
agents_and_robots/pkg/tools/devicemesh/tools_builtin.go
T
egutierrez bcd246bf85 feat(0144a): tool registry framework para device-mesh
Anade pkg/tools/devicemesh con Client HTTP al device_agent + ToolRegistry
con 16 tools standard (exec, fs.*, git.*, docker.*, proc.*, pkg.*, shell.eval).
RegisterBuiltins filtra por mode user/sudo via RequiresApproval flag.
Hook al pkg/decision con ActionKindDeviceMesh + DeviceMeshAction.
Runner soporta dispatch via NewRunnerWithDeviceMesh (back-compat NewRunner).

Tests: 25 nuevos en devicemesh + 4 en runner. Build clean.
2026-05-24 14:07:13 +02:00

776 lines
23 KiB
Go

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
}