From 9ec05e2e3f625c8055dc98ecf367b7e233d15bd6 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 15 Aug 2026 17:50:56 +0200 Subject: [PATCH] feat: add docker_exec MCP tool for ergonomic container commands on LXCs - New MCP tool wraps with proper escaping - Resolves LXC target from entity graph (no hardcoded IPs) - Uses classifyAndGate for classification + approval chain - Read-only commands (curl GET, cat, ls) auto-execute - Mutations (POST/PUT/DELETE) require operator approval - Full audit trail via execution rows - Updates AGENTS.md with tool listing --- AGENTS.md | 1 + internal/mcp/ops_tools.go | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f10324a..5f2a24d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ elsewhere; regenerate from `internal/mcp/` when tools change): ping_service(service_slug) — HTTP reachability + scheduler health state list_lxcs(state) — all LXC containers with ID, host, IP, last-audited hint restart_service(target, service) — restart a systemd service (config_mutation, requires approval) + docker_exec(lxc_slug, container, command, purpose) — run a command inside a Docker container on an LXC; handles escaping, resolves target from entity graph; read-only auto-runs, mutations require approval push_file(target, source_path, dest_path, backup=true) — push a file into an LXC from the Proxmox host (config_mutation, requires approval) ack_signal(signal_id) — acknowledge an open signal resolve_signal(signal_id, resolution) — resolve a signal with optional note diff --git a/internal/mcp/ops_tools.go b/internal/mcp/ops_tools.go index 651f56d..1a4bd54 100644 --- a/internal/mcp/ops_tools.go +++ b/internal/mcp/ops_tools.go @@ -48,6 +48,58 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil }}, + // docker_exec wraps a command inside a Docker container on an LXC. + // Resolves the LXC, looks up the pve_id, and runs via + // `pct exec -- docker exec sh -c '...'`. + // Uses the same classifyAndGate safety path as `run`. + {tool: &mcp.Tool{Name: "docker_exec", Description: "Run a shell command inside a Docker container on an LXC. Use when you need to query an app's REST API inside a docker container (curl, Sonarr/Radarr API calls), read its logs or config from inside the container, or make config changes. This is the ergonomic replacement for `run(target, 'docker exec ...')` — it handles command escaping, container resolution, and classification. Commands that mutate container state (POST/PUT/DELETE /api/, file writes) classify as config_mutation and require approval. Reads (GET, cat, ls, status checks) auto-run.", + InputSchema: objSchema( + prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:arriman, lxc:caddy). The LXC must be active and running Docker."}, + prop{"container", "string", "Docker container name inside the LXC (e.g. prowlarr, sonarr)."}, + prop{"command", "string", "The shell command to run inside the container. Runs via `docker exec sh -c '...'`. Shell escaping is handled for you."}, + prop{"purpose", "string", "One sentence: why you're running this. Shown alongside any approval request."}, + prop{"declared_risk", "string", "Optional self-assessment: read_only, config_mutation. Override the auto-classification."}, + ), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + lxcSlug, _ := args["lxc_slug"].(string) + container, _ := args["container"].(string) + command, _ := args["command"].(string) + purpose, _ := args["purpose"].(string) + declaredRisk, _ := args["declared_risk"].(string) + sessionID, _ := args["_session_id"].(string) + + if lxcSlug == "" || container == "" || command == "" { + return textResult("error: lxc_slug, container, and command are required"), nil + } + if purpose == "" { + return textResult("error: purpose is required"), nil + } + + // Normalize slug prefix + if !strings.HasPrefix(lxcSlug, "lxc:") { + lxcSlug = "lxc:" + lxcSlug + } + + // Look up the LXC entity + var targetID uuid.UUID + var pveID string + if err := pool.QueryRow(ctx, + `SELECT id, COALESCE(attributes->>'pve_id', '') FROM entities WHERE slug = $1`, + lxcSlug).Scan(&targetID, &pveID); err != nil { + return textResult(fmt.Sprintf("LXC not found: %s", lxcSlug)), nil + } + if pveID == "" { + return textResult(fmt.Sprintf("LXC %s has no pve_id attribute — can't resolve Proxmox host", lxcSlug)), nil + } + + // Escape the command for safe sh -c '...' inside docker exec. + // The standard trick: replace ' with '\'' then wrap in single quotes. + noQuote := strings.ReplaceAll(command, "'", "'\\''") + dockerCmd := fmt.Sprintf("docker exec %s sh -c '%s'", container, noQuote) + + return classifyAndGate(ctx, pool, agentID, targetID, lxcSlug, dockerCmd, purpose, declaredRisk, sessionID), nil + }}, // inspect_path is the bulk fact-gathering tool from // plans/2026-07-18-session-review-three-sessions.md P1.5. // Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls