From 8fbe39cf2a903823e8cecda1202cf0eaff7b665c Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 15 Aug 2026 17:38:26 +0200 Subject: [PATCH] fix: transport escalation exempts log reads, get_entity accepts slug alias, add restart_service + push_file tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Transport escalation in classifyAndGate (server.go:745) now exempts log-inspection commands (tail/head/cat/journalctl on *.log or /logs/) from the /opt//etc//var/lib/ gating on LXC targets. Fixes the 'tail -3 /opt/seanime/data/logs/seanime.log queued for approval' bug. P1: queryEntity returns actionable error when slug_or_id param is empty ('slug_or_id is required') instead of silent 'entity not found: '. P2: Added slugArg() helper (server.go) so get_entity, get_relations, get_blast_radius, and explain accept 'slug' as an alias for their declared param name. Solves the discoverability inconsistency where every tool used a different param name for the same concept. P3: Two new MCP tools: - restart_service(target, service) — systemctl restart wrapper, correctly classified config_mutation (requires approval) - push_file(target, source_path, dest_path, backup=true) — pct push from Proxmox host into LXC, with optional backup. Classified config_mutation. LXC-only for now. P4: Updated homelab-lxc-ops skill with MCP tools preference table. Plus: Wails desktop build now uses build-tag approach for frontend embed (assets_embed.go + assets_stub.go), so go build ./... works on clean checkout without the frontend built first. Version: 0.31.0 → 0.32.0 --- .gitignore | 2 +- AGENTS.md | 7 ++- Makefile | 2 +- VERSION | 2 +- cmd/desktop/assets_embed.go | 12 +++++ cmd/desktop/assets_stub.go | 12 +++++ cmd/desktop/frontend/dist/.gitkeep | 1 + cmd/desktop/main.go | 8 ++-- internal/mcp/entity_tools.go | 6 +-- internal/mcp/ops_tools.go | 77 ++++++++++++++++++++++++++++++ internal/mcp/server.go | 45 ++++++++++++++++- 11 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 cmd/desktop/assets_embed.go create mode 100644 cmd/desktop/assets_stub.go create mode 100644 cmd/desktop/frontend/dist/.gitkeep diff --git a/.gitignore b/.gitignore index c085593..5cdf522 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ web/node_modules/ cmd/desktop/frontend/dist/ cmd/desktop/build/ cmd/desktop/Oikos -desktop +/desktop /eval # Local tooling artifacts (Playwright MCP session logs, stray screenshots) diff --git a/AGENTS.md b/AGENTS.md index 6ebf005..f10324a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,9 +56,12 @@ Endpoint: `https://mcp.hubris.network/mcp`. Every call needs enrollment and `/healthz` (see "Authentication" below for where the token comes from). -Available tools (63 total — the authoritative list; do not hardcode the count +Available tools (65 total — the authoritative list; do not hardcode the count elsewhere; regenerate from `internal/mcp/` when tools change): + 💡 Slug param alias: all entity-lookup tools now accept `slug` in addition to + their declared param name (e.g. `get_entity(slug="lxc:seanime")` works). + Entity Tools — knowledge graph, discovery, and lifecycle: ping — lightweight connectivity check get_entity(slug_or_id) — get an entity by slug or UUID @@ -88,6 +91,8 @@ elsewhere; regenerate from `internal/mcp/` when tools change): get_lxc_state(lxc_slug) — pct status from Proxmox host ping_service(service_slug) — HTTP reachability + scheduler health state 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) + 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 resolve_signal(signal_id, resolution) — resolve a signal with optional note mute_signal(signal_id, duration_s=3600) — temporarily mute a signal diff --git a/Makefile b/Makefile index 2f58b86..cc597ef 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ desktop: ui ## Build the Wails desktop app for the current platform rm -rf cmd/desktop/frontend/dist mkdir -p cmd/desktop/frontend/dist cp -r web/dist/* cmd/desktop/frontend/dist/ - cd cmd/desktop && CGO_ENABLED=1 go build -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos . + cd cmd/desktop && CGO_ENABLED=1 go build -tags desktop -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos . desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux) @case $$(uname -s) in \ diff --git a/VERSION b/VERSION index 26bea73..9eb2aa3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.31.0 +0.32.0 diff --git a/cmd/desktop/assets_embed.go b/cmd/desktop/assets_embed.go new file mode 100644 index 0000000..9c207be --- /dev/null +++ b/cmd/desktop/assets_embed.go @@ -0,0 +1,12 @@ +//go:build desktop + +package main + +import "embed" + +// assets is the embedded web SPA. Built only with the `desktop` tag, which is +// set by `make desktop` after it copies web/dist/* into cmd/desktop/frontend/dist +// (a gitignored build artifact). See assets_stub.go for the default build. +// +//go:embed frontend/dist +var assets embed.FS diff --git a/cmd/desktop/assets_stub.go b/cmd/desktop/assets_stub.go new file mode 100644 index 0000000..dd23dc1 --- /dev/null +++ b/cmd/desktop/assets_stub.go @@ -0,0 +1,12 @@ +//go:build !desktop + +package main + +import "embed" + +// assets is an empty FS for the default (non-desktop) build. The real embedded +// SPA lives in assets_embed.go behind the `desktop` build tag, because +// frontend/dist is a gitignored artifact that only exists after `make desktop` +// copies web/dist/* into it. This stub lets `go build ./...` compile cleanly on +// a fresh checkout without the frontend built. +var assets embed.FS diff --git a/cmd/desktop/frontend/dist/.gitkeep b/cmd/desktop/frontend/dist/.gitkeep new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/cmd/desktop/frontend/dist/.gitkeep @@ -0,0 +1 @@ +placeholder diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 20b1cab..c233d09 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -3,7 +3,7 @@ package main import ( "crypto/rand" "crypto/sha256" - "embed" + _ "embed" // required by the //go:embed icon.png directive below "encoding/base64" "encoding/json" "fmt" @@ -27,8 +27,10 @@ import ( "github.com/zalando/go-keyring" ) -//go:embed frontend/dist -var assets embed.FS +// assets is the embedded web SPA, defined in assets_embed.go (`desktop` build +// tag, real //go:embed frontend/dist) and assets_stub.go (default build, empty +// FS). frontend/dist is a gitignored artifact populated by `make desktop`; the +// stub keeps `go build ./...` working on a clean checkout. //go:embed icon.png var iconPNG []byte diff --git a/internal/mcp/entity_tools.go b/internal/mcp/entity_tools.go index 2fe4181..c5db960 100644 --- a/internal/mcp/entity_tools.go +++ b/internal/mcp/entity_tools.go @@ -24,7 +24,7 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) - idOrSlug, _ := args["slug_or_id"].(string) + idOrSlug := slugArg(args, "slug_or_id", "slug", "id") return queryEntity(ctx, pool, idOrSlug), nil }}, {tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search", @@ -52,7 +52,7 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) - slug, _ := args["entity_id"].(string) + slug := slugArg(args, "entity_id", "slug") typesStr, _ := args["types"].(string) if slug == "" { return textResult("entity_id is required"), nil @@ -76,7 +76,7 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg prop{"depth", "integer", "Traversal depth (default 3)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) - slug, _ := args["entity_id"].(string) + slug := slugArg(args, "entity_id", "slug") depth := int(getFloat(args, "depth", 3)) return queryRows(ctx, pool, "SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id", diff --git a/internal/mcp/ops_tools.go b/internal/mcp/ops_tools.go index 1bbf6c8..651f56d 100644 --- a/internal/mcp/ops_tools.go +++ b/internal/mcp/ops_tools.go @@ -595,5 +595,82 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg { } return textResult(fmt.Sprintf("secret %s stored", key)), nil }}, + {tool: &mcp.Tool{Name: "restart_service", Description: "Restart a systemd service on a host or LXC. Wraps `systemctl restart ` with correct risk classification (config_mutation — requires operator approval). Prefer this over raw `run` for restarts: it resolves the target, sets the risk class, and records a clean execution row.", + InputSchema: objSchema( + prop{"target", "string", "Target entity slug (e.g. lxc:seanime, host:hubris)"}, + prop{"service", "string", "systemd unit name (e.g. seanime, caddy)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + targetSlug := slugArg(args, "target", "service_slug", "slug") + service := slugArg(args, "service", "unit") + if targetSlug == "" || service == "" { + return textResult("error: target and service are required"), nil + } + sessionID, _ := args["_session_id"].(string) + var targetID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { + return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil + } + command := fmt.Sprintf("systemctl restart %s", service) + purpose := fmt.Sprintf("restart %s on %s", service, targetSlug) + return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, "config_mutation", sessionID), nil + }}, + {tool: &mcp.Tool{Name: "push_file", Description: "Copy a file from a Proxmox host into an LXC container via `pct push`. The source path must already exist on the Proxmox host that owns the LXC (stage it there first via `run` on the host, e.g. with scp/curl/wget). Classified config_mutation — requires operator approval. Prefer this over manual 3-hop SSH piping (`cat | ssh | pct exec tee`), which repeatedly drops into partial-write/text-file-busy states.", + InputSchema: objSchema( + prop{"target", "string", "LXC entity slug (e.g. lxc:seanime)"}, + prop{"source_path", "string", "Path on the Proxmox host (e.g. /tmp/seanime)"}, + prop{"dest_path", "string", "Destination path inside the container (e.g. /opt/seanime/bin/seanime)"}, + prop{"backup", "boolean", "If true, copy the existing dest to dest.bak inside the container first (default true)"}), + }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + targetSlug := slugArg(args, "target", "slug") + sourcePath := slugArg(args, "source_path") + destPath := slugArg(args, "dest_path") + backup := true + if b, ok := args["backup"].(bool); ok { + backup = b + } + if targetSlug == "" || sourcePath == "" || destPath == "" { + return textResult("error: target, source_path, and dest_path are required"), nil + } + if !strings.HasPrefix(targetSlug, "lxc:") { + return textResult(fmt.Sprintf("error: push_file only supports lxc: targets for now (got %s) — host/vm push needs scp, not yet wired", targetSlug)), nil + } + // Resolve pve_id and the owning Proxmox host (same chain as get_lxc_state). + 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 or missing pve_id: %s", targetSlug)), nil + } + var hostID uuid.UUID + err := pool.QueryRow(ctx, ` + SELECT t.id FROM entities t + JOIN relationships r ON r.source_id = t.id + JOIN entities s ON s.id = r.target_id + WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL + LIMIT 1`, targetSlug).Scan(&hostID) + var hostSlug string + if err == nil { + pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug) + } else { + pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", targetSlug).Scan(&hostSlug) + } + if hostSlug == "" { + return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", targetSlug)), nil + } + cmd := fmt.Sprintf("pct push %s %s %s", pveID, sourcePath, destPath) + if backup { + cmd = fmt.Sprintf("pct exec %s -- cp -n %s %s.bak 2>/dev/null || true; %s", pveID, destPath, destPath, cmd) + } + sessionID, _ := args["_session_id"].(string) + var hostEntityID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", "host:"+hostSlug).Scan(&hostEntityID); err != nil { + // hostSlug may already carry the host: prefix + if err2 := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", hostSlug).Scan(&hostEntityID); err2 != nil { + return textResult(fmt.Sprintf("host entity not found: %s", hostSlug)), nil + } + } + purpose := fmt.Sprintf("push %s into %s at %s", sourcePath, targetSlug, destPath) + return classifyAndGate(ctx, pool, agentID, hostEntityID, "host:"+hostSlug, cmd, purpose, "config_mutation", sessionID), nil + }}, } } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 625f190..1ed6f52 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -300,6 +300,22 @@ func argsMap(req *mcp.CallToolRequest) map[string]any { return m } +// slugArg returns the first non-empty string value among the given keys. +// Tools historically used inconsistent param names for "the entity slug" +// (slug_or_id, entity_id, service_slug, lxc_slug, target). This lets a single +// handler accept any of them, so an agent guessing "slug" still works. +func slugArg(m map[string]any, keys ...string) string { + if m == nil { + return "" + } + for _, k := range keys { + if s, ok := m[k].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return "" +} + func getFloat(m map[string]any, key string, def float64) float64 { if m == nil { return def @@ -353,6 +369,9 @@ func jsonErr(format string, args ...any) []byte { } func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult { + if strings.TrimSpace(idOrSlug) == "" { + return textResult("slug_or_id is required — expected an entity slug (e.g. lxc:seanime) or UUID") + } var id uuid.UUID if u, err := uuid.Parse(idOrSlug); err == nil { id = u @@ -742,9 +761,15 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid. // the same command locally on the Proxmox host via pct exec. // Caught live: "cat /etc/hostname" on lxc:dns queued as config_mutation // while "pct exec 107 -- cat /etc/hostname" on host:hubris auto-ran. + // + // EXEMPTION: tail/head/cat/less/journalctl on log files (*.log, */logs/*) + // are always read-only — caught live 2026-08-15 session f6a7d4e: + // "tail -3 /opt/seanime/data/logs/seanime.log" on lxc:seanime was gated. if riskClass == policy.RiskReadOnly && strings.HasPrefix(targetSlug, "lxc:") { - if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") { - riskClass = policy.RiskConfigMutation + if !isLogInspectionRead(command) { + if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") { + riskClass = policy.RiskConfigMutation + } } } @@ -1134,6 +1159,22 @@ func hostLxcCommand(cmd string) (string, bool) { return first, hostLxcCommands[first] } +// isLogInspectionRead returns true if the command is a safe read-only operation +// on a log file — tail, head, cat, less, or journalctl with a .log or /logs/ path. +// Used by the transport-aware escalation check to avoid gating the most common +// debugging action (e.g. "tail -3 /opt/seanime/data/logs/seanime.log" on lxc:seanime). +func isLogInspectionRead(cmd string) bool { + trimmed := strings.TrimSpace(cmd) + for _, prefix := range []string{"tail ", "head ", "cat ", "less ", "journalctl "} { + if strings.HasPrefix(trimmed, prefix) { + if strings.Contains(trimmed, ".log") || strings.Contains(trimmed, "/logs/") { + return true + } + } + } + return false +} + // validateCommandSyntax checks for common LLM-generated bash errors that always // fail at the shell. Returns an error message or "" if the command looks valid. func validateCommandSyntax(cmd string) string {