feat: add docker_exec MCP tool for ergonomic container commands on LXCs
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- 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
This commit is contained in:
hermes
2026-08-15 17:50:56 +02:00
parent 8fbe39cf2a
commit 9ec05e2e3f
2 changed files with 53 additions and 0 deletions

View File

@@ -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

View File

@@ -47,6 +47,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 <pve_id> -- docker exec <container> 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 <container> ...')` — 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 <container> 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.