bcd246bf85
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.
776 lines
23 KiB
Go
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
|
|
}
|