fix: transport escalation exempts log reads, get_entity accepts slug alias, add restart_service + push_file tools
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
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,7 +22,7 @@ web/node_modules/
|
|||||||
cmd/desktop/frontend/dist/
|
cmd/desktop/frontend/dist/
|
||||||
cmd/desktop/build/
|
cmd/desktop/build/
|
||||||
cmd/desktop/Oikos
|
cmd/desktop/Oikos
|
||||||
desktop
|
/desktop
|
||||||
/eval
|
/eval
|
||||||
|
|
||||||
# Local tooling artifacts (Playwright MCP session logs, stray screenshots)
|
# Local tooling artifacts (Playwright MCP session logs, stray screenshots)
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ Endpoint: `https://mcp.hubris.network/mcp`. Every call needs
|
|||||||
enrollment and `/healthz` (see "Authentication" below for where the token
|
enrollment and `/healthz` (see "Authentication" below for where the token
|
||||||
comes from).
|
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):
|
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:
|
Entity Tools — knowledge graph, discovery, and lifecycle:
|
||||||
ping — lightweight connectivity check
|
ping — lightweight connectivity check
|
||||||
get_entity(slug_or_id) — get an entity by slug or UUID
|
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
|
get_lxc_state(lxc_slug) — pct status from Proxmox host
|
||||||
ping_service(service_slug) — HTTP reachability + scheduler health state
|
ping_service(service_slug) — HTTP reachability + scheduler health state
|
||||||
list_lxcs(state) — all LXC containers with ID, host, IP, last-audited hint
|
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
|
ack_signal(signal_id) — acknowledge an open signal
|
||||||
resolve_signal(signal_id, resolution) — resolve a signal with optional note
|
resolve_signal(signal_id, resolution) — resolve a signal with optional note
|
||||||
mute_signal(signal_id, duration_s=3600) — temporarily mute a signal
|
mute_signal(signal_id, duration_s=3600) — temporarily mute a signal
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -66,7 +66,7 @@ desktop: ui ## Build the Wails desktop app for the current platform
|
|||||||
rm -rf cmd/desktop/frontend/dist
|
rm -rf cmd/desktop/frontend/dist
|
||||||
mkdir -p cmd/desktop/frontend/dist
|
mkdir -p cmd/desktop/frontend/dist
|
||||||
cp -r web/dist/* 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)
|
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
|
||||||
@case $$(uname -s) in \
|
@case $$(uname -s) in \
|
||||||
|
|||||||
12
cmd/desktop/assets_embed.go
Normal file
12
cmd/desktop/assets_embed.go
Normal file
@@ -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
|
||||||
12
cmd/desktop/assets_stub.go
Normal file
12
cmd/desktop/assets_stub.go
Normal file
@@ -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
|
||||||
1
cmd/desktop/frontend/dist/.gitkeep
vendored
Normal file
1
cmd/desktop/frontend/dist/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
placeholder
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"embed"
|
_ "embed" // required by the //go:embed icon.png directive below
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -27,8 +27,10 @@ import (
|
|||||||
"github.com/zalando/go-keyring"
|
"github.com/zalando/go-keyring"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed frontend/dist
|
// assets is the embedded web SPA, defined in assets_embed.go (`desktop` build
|
||||||
var assets embed.FS
|
// 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
|
//go:embed icon.png
|
||||||
var iconPNG []byte
|
var iconPNG []byte
|
||||||
|
|||||||
@@ -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"}),
|
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) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
idOrSlug, _ := args["slug_or_id"].(string)
|
idOrSlug := slugArg(args, "slug_or_id", "slug", "id")
|
||||||
return queryEntity(ctx, pool, idOrSlug), nil
|
return queryEntity(ctx, pool, idOrSlug), nil
|
||||||
}},
|
}},
|
||||||
{tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
{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) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
slug, _ := args["entity_id"].(string)
|
slug := slugArg(args, "entity_id", "slug")
|
||||||
typesStr, _ := args["types"].(string)
|
typesStr, _ := args["types"].(string)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
return textResult("entity_id is required"), nil
|
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)"}),
|
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
||||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
slug, _ := args["entity_id"].(string)
|
slug := slugArg(args, "entity_id", "slug")
|
||||||
depth := int(getFloat(args, "depth", 3))
|
depth := int(getFloat(args, "depth", 3))
|
||||||
return queryRows(ctx, pool,
|
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",
|
"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",
|
||||||
|
|||||||
@@ -595,5 +595,82 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
|||||||
}
|
}
|
||||||
return textResult(fmt.Sprintf("secret %s stored", key)), nil
|
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 <service>` 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
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,6 +300,22 @@ func argsMap(req *mcp.CallToolRequest) map[string]any {
|
|||||||
return m
|
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 {
|
func getFloat(m map[string]any, key string, def float64) float64 {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return def
|
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 {
|
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
|
var id uuid.UUID
|
||||||
if u, err := uuid.Parse(idOrSlug); err == nil {
|
if u, err := uuid.Parse(idOrSlug); err == nil {
|
||||||
id = u
|
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.
|
// the same command locally on the Proxmox host via pct exec.
|
||||||
// Caught live: "cat /etc/hostname" on lxc:dns queued as config_mutation
|
// Caught live: "cat /etc/hostname" on lxc:dns queued as config_mutation
|
||||||
// while "pct exec 107 -- cat /etc/hostname" on host:hubris auto-ran.
|
// 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 riskClass == policy.RiskReadOnly && strings.HasPrefix(targetSlug, "lxc:") {
|
||||||
if strings.Contains(command, "/opt/") || strings.Contains(command, "/etc/") || strings.Contains(command, "/var/lib/") {
|
if !isLogInspectionRead(command) {
|
||||||
riskClass = policy.RiskConfigMutation
|
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]
|
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
|
// validateCommandSyntax checks for common LLM-generated bash errors that always
|
||||||
// fail at the shell. Returns an error message or "" if the command looks valid.
|
// fail at the shell. Returns an error message or "" if the command looks valid.
|
||||||
func validateCommandSyntax(cmd string) string {
|
func validateCommandSyntax(cmd string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user