mcp: expand request_execution — restart, systemctl, pct_exec, apt_upgrade with inline SSH

This commit is contained in:
2026-07-07 23:07:02 +02:00
parent 692800cf09
commit 7ac2521a22

View File

@@ -223,17 +223,19 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
nStr(args["status"])), nil
})
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (Hermes-only mutation path)",
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (Hermes-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug"},
prop{"action", "string", "Action to perform"},
prop{"target", "string", "Target entity slug (e.g. lxc:caddy)"},
prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade"},
prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade'"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug, _ := args["target"].(string)
action, _ := args["action"].(string)
params, _ := args["params"].(string)
if targetSlug == "" || action == "" {
return textResult("error: target and action are required"), nil
return textResult("error: target and action required"), nil
}
var targetID uuid.UUID
@@ -241,34 +243,97 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
}
id, err := uuid.NewV7()
if err != nil {
return textResult(fmt.Sprintf("uuid error: %v", err)), nil
}
id, _ := uuid.NewV7()
correlationID := uuid.New().String()
execName := action + " on " + targetSlug + " (" + id.String()[:8] + ")"
// Write execution record
execSlug := "exec:" + targetSlug + ":" + id.String()[:8]
pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
id, execSlug, action+" on "+targetSlug)
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5)`,
id, targetID, action+":"+params, correlationID, agentID)
_, err = pool.Exec(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, 'execution', $3, '{}')`,
id, execSlug, execName)
if err != nil {
return textResult(fmt.Sprintf("insert entity: %v", err)), nil
// Execute reversible actions immediately
switch action {
case "restart":
host, user, err := resolveHost(ctx, pool, targetSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
svc := strings.TrimPrefix(targetSlug, "lxc:")
out, err := sshExec(ctx, host, user, fmt.Sprintf("systemctl restart %s 2>&1; sleep 1; systemctl is-active %s", svc, svc))
result := fmt.Sprintf("restart %s: %s", svc, out)
if err != nil {
result = fmt.Sprintf("restart %s: ERROR %v", svc, err)
}
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n")))
return textResult(result), nil
case "systemctl":
host, user, err := resolveHost(ctx, pool, targetSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
svc := strings.TrimPrefix(targetSlug, "lxc:")
cmd := fmt.Sprintf("systemctl %s %s 2>&1; sleep 1; systemctl is-active %s", params, svc, svc)
if params == "enable" || params == "disable" {
// config_mutation — mark as pending for operator approval
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil
}
out, err := sshExec(ctx, host, user, cmd)
result := fmt.Sprintf("systemctl %s %s: %s", params, svc, out)
if err != nil {
result = fmt.Sprintf("systemctl %s %s: ERROR %v", params, svc, err)
}
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n")))
return textResult(result), nil
case "pct_exec":
var pveID string
if err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", targetSlug).Scan(&pveID); err != nil || pveID == "" {
return textResult(fmt.Sprintf("LXC not found: %s", targetSlug)), nil
}
// Resolve Proxmox host
var hostSlug string
pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", targetSlug).Scan(&hostSlug)
if hostSlug == "" {
hostSlug = "host:hubris" // default
}
host, user, err := resolveHost(ctx, pool, hostSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve Proxmox host: %v", err)), nil
}
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct exec %s -- %s 2>&1", pveID, params))
result := fmt.Sprintf("pct exec %s: %s", pveID, out)
if err != nil {
result = fmt.Sprintf("pct exec %s: ERROR %v", pveID, err)
}
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n")))
return textResult(result), nil
case "apt_upgrade":
if params == "audit" {
host, user, err := resolveHost(ctx, pool, targetSlug)
if err != nil {
return textResult(fmt.Sprintf("resolve: %v", err)), nil
}
out, err := sshExec(ctx, host, user, "apt update -qq 2>&1 >/dev/null; apt list --upgradable 2>/dev/null | tail -n +2 | wc -l; apt list --upgradable 2>/dev/null | tail -n +2 | head -20")
if err != nil {
return textResult(fmt.Sprintf("apt audit error: %v", err)), nil
}
return textResult("apt audit:\n" + out), nil
}
// upgrade requires approval — queue
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil
default:
return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade", action)), nil
}
_, err = pool.Exec(ctx, `
INSERT INTO executions (entity_id, target_entity_id, action, risk_class,
status, correlation_id, agent_id)
VALUES ($1, $2, $3, 'unclassified', 'pending', $4, $5)`,
id, targetID, action, correlationID, agentID)
if err != nil {
return textResult(fmt.Sprintf("insert execution: %v", err)), nil
}
return textResult(fmt.Sprintf("execution requested: id=%s target=%s action=%s correlation=%s",
id, targetSlug, action, correlationID)), nil
})
register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",