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
This commit is contained in:
@@ -92,6 +92,7 @@ elsewhere; regenerate from `internal/mcp/` when tools change):
|
|||||||
ping_service(service_slug) — HTTP reachability + scheduler health state
|
ping_service(service_slug) — HTTP reachability + scheduler health state
|
||||||
list_lxcs(state) — all LXC containers with ID, host, IP, last-audited hint
|
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)
|
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)
|
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
|
ack_signal(signal_id) — acknowledge an open signal
|
||||||
resolve_signal(signal_id, resolution) — resolve a signal with optional note
|
resolve_signal(signal_id, resolution) — resolve a signal with optional note
|
||||||
|
|||||||
@@ -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
|
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
|
// inspect_path is the bulk fact-gathering tool from
|
||||||
// plans/2026-07-18-session-review-three-sessions.md P1.5.
|
// plans/2026-07-18-session-review-three-sessions.md P1.5.
|
||||||
|
|||||||
Reference in New Issue
Block a user