From 1b9c761274f5450a131c20eb60d3f9f4ea2cf983 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 5 Aug 2026 15:25:14 +0200 Subject: [PATCH] =?UTF-8?q?implements=20plan:=20agent=20execution=20safety?= =?UTF-8?q?=20=E2=80=94=20QEMU=20guest=20agent=20gate=20+=20health=20guard?= =?UTF-8?q?=20+=20policy=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/mcp/server.go | 22 ++++++++++++++++++++++ internal/mcp/tools.go | 22 +++++++++++++++++++++- seeds/inventory.yaml | 10 ++++++---- seeds/policy.yaml | 5 +++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9054432..2154c85 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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. diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 60a6bdb..2ac0ae6 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -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: (e.g. host:strong), lxc: (e.g. lxc:caddy), or vm: (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."}, diff --git a/seeds/inventory.yaml b/seeds/inventory.yaml index 1b346d7..d125dbc 100644 --- a/seeds/inventory.yaml +++ b/seeds/inventory.yaml @@ -87,10 +87,12 @@ entities: mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}} ssh: {user: root} note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey - monitoring: none # host unreachable from the lab (no ICMP, port 22 - # times out even via hubris); liveness is covered - # by its services — authentik/matrix http checks - # and the matrix cert-expiry dial it on :443 + monitoring: [http] # public HTTPS probe via https://mcp.hubris.network + # (2026-08-05: was `none` — the VPS went silent for + # 7 days because nothing probed it. The standalone-server + # type inherits [ping,resource,updates] from machine, but + # SSH/ICMP don't reach it from the lab; an HTTP probe on + # the public endpoint is the reachable liveness signal). - slug: "ws:mac-mini" type: workstation name: mac-mini diff --git a/seeds/policy.yaml b/seeds/policy.yaml index 57de670..2808680 100644 --- a/seeds/policy.yaml +++ b/seeds/policy.yaml @@ -70,6 +70,11 @@ approval_rules: - {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto} - {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate} - {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate} + # host-level package/kernel install (apt-get install, dpkg, modprobe, systemctl enable) + # always classifies as config_mutation — the classifier defaults to config_mutation + # for any command not in the read-only allowlist, so apt-get install reaches this + # tier naturally. Documented explicitly here so agents stop second-guessing: + # host mutations always need operator approval. - {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never} - {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate} - {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate}