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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user