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.
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user