implements plan: agent execution safety — QEMU guest agent gate + health guard + policy docs

I — run pre-flights QEMU guest agent before queueing VM execution
  classifyAndGate now checks vm: targets for qemu_guest_agent attribute.
  If not_running/missing, returns immediate error instead of queuing forever.

II — policy.yaml: documented host-mutation classifier rule
  Added comment clarifying that host-level package/kernel mutations
  (apt-get install, dpkg, systemctl enable) always classify as
  config_mutation and thus need operator approval.

III — health attribute read-only in update_entity_attributes
  Strips scheduler-owned keys (health, last_check_at, last_check) from
  attribute updates with a clear message directing agents to
  get_health_summary / list_checks instead.

IV — Recorded discovered dependency edges
  vm:zimaos → depends-on → lxc:nfs-export (NFS /media/library mount)
  vm:zimaos → depends-on → host:strong (NFS /media/ludo-library mount)

Also updated the run tool description to mention both guardrails.
This commit is contained in:
2026-08-05 15:25:14 +02:00
parent a126cfa710
commit 1b9c761274
4 changed files with 54 additions and 5 deletions

View File

@@ -872,6 +872,28 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
}
}
// VM transport pre-flight: qm guest exec requires the QEMU guest agent
// to be running inside the VM. If it's not, the execution would queue
// for approval and never execute — the agent has no way to learn it's
// stuck (spotted live 2026-08-05: vm:zimaos had qemu_guest_agent=not_running,
// the run queued forever, and the agent fell back to unsafe raw SSH).
if strings.HasPrefix(targetSlug, "vm:") {
var rawAttrs []byte
if err := pool.QueryRow(ctx, `SELECT attributes FROM entities WHERE id = $1`, targetID).Scan(&rawAttrs); err == nil {
var attrs map[string]any
if json.Unmarshal(rawAttrs, &attrs) == nil {
if qga, ok := attrs["qemu_guest_agent"]; ok {
qgaStr, _ := qga.(string)
if qgaStr == "not_running" || qgaStr == "" {
return textResult(fmt.Sprintf(
"run on %s blocked: QEMU guest agent is not running (%s). qm guest exec cannot reach this VM. Start the agent inside the guest first (e.g. via SSH/systemctl start qemu-guest-agent), then re-run. If the agent is running but the entity attribute is stale, update it with update_entity_attributes(slug=%s, attributes={\"qemu_guest_agent\":\"running\"}).",
targetSlug, qgaStr, targetSlug))
}
}
}
}
}
// Dedup: an identical pending command (same target, command, and
// purpose) blocks a re-request — stops a tool-calling loop from queuing
// the same approval repeatedly.

View File

@@ -324,6 +324,26 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
// Strip scheduler-owned keys: health is computed by the scheduler
// from probe results (spotted live 2026-08-05: an agent set
// health:"healthy" on lxc:nfs-export, which derived 4 spurious checks).
// Agents can observe health via get_health_summary / list_checks.
var blocked []string
for _, key := range []string{"health", "last_check_at", "last_check"} {
if _, ok := attrs[key]; ok {
delete(attrs, key)
blocked = append(blocked, key)
}
}
if len(blocked) > 0 {
// Re-marshal the filtered attrs
filtered, _ := json.Marshal(attrs)
attrsStr = string(filtered)
if len(attrs) == 0 {
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
}
}
attrsJSON, _ := json.Marshal(attrs)
// Run the merge + check regeneration in one transaction so the
@@ -554,7 +574,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
// for future runbook extraction — especially pct_create DNS/VMID logic.
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.\n\nHost-level mutations (apt-get install, dpkg, systemctl enable) always classify as config_mutation — operator approval required.\n\nVM targets: the QEMU guest agent must be running inside the VM. If the entity's qemu_guest_agent attribute is not_running, the run is blocked immediately with a clear error.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},