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:
@@ -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 <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.
|
||||
// Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls
|
||||
|
||||
Reference in New Issue
Block a user