Files
pulse/internal/ai/tools/tools_query.go
T
rcourtman 57ead19484 Preserve Patrol evidence and surface action submission failures
Live funded qualification found hidden tool results and misleading action
submission outcomes. Share the result-bearing transcript across stored chat
and product history, render the retained evidence, and distinguish captured
proposals from broker acceptance. Keep review usable while Patrol is paused.

Record Gemini route pricing and exact qualification limits. Integrate current
main and repeat browser proof for the incoming login flow. Approved/rejected
recovery remains unqualified without the development command agent.
2026-09-06 20:09:54 +01:00

6227 lines
210 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
// routingMismatchLogLimiter provides rate limiting for routing mismatch debug logs.
// This prevents log spam while still providing visibility into routing issues.
var routingMismatchLogLimiter = struct {
mu sync.Mutex
lastLog time.Time
interval time.Duration
}{
interval: 10 * time.Second, // Log at most once per 10 seconds
}
// ErrStrictResolution is returned when a write operation is attempted on an
// undiscovered resource while PULSE_STRICT_RESOLUTION is enabled.
// Use errors.As() to check for this error type.
type ErrStrictResolution struct {
ResourceID string // The resource identifier that wasn't found
Action string // The action that was attempted
Message string // Human-readable message
}
func (e *ErrStrictResolution) Error() string {
return e.Message
}
// Code returns the error code for structured responses
func (e *ErrStrictResolution) Code() string {
return ErrCodeStrictResolution
}
// ToToolResponse returns a consistent ToolResponse for blocked operations.
// The model receives the policy facts and decides any follow-up tool use.
func (e *ErrStrictResolution) ToToolResponse() ToolResponse {
return NewToolBlockedError(
ErrCodeStrictResolution,
e.Message,
map[string]interface{}{
"resource_id": e.ResourceID,
"action": e.Action,
"policy_boundary": "Resource discovery is required before resource-specific actions.",
},
)
}
// ToStructuredError returns a structured error payload for tool responses
// Deprecated: Use ToToolResponse() for consistent envelope
func (e *ErrStrictResolution) ToStructuredError() map[string]interface{} {
return map[string]interface{}{
"error_code": e.Code(),
"message": e.Message,
"resource_id": e.ResourceID,
"action": e.Action,
}
}
// ErrRoutingMismatch is returned when a tool targets a parent host (e.g., a hypervisor node)
// but the session has discovered more specific child resources (system containers/VMs) on that host.
// This prevents accidentally operating on the host filesystem when the user intended
// to target a guest resource.
type ErrRoutingMismatch struct {
TargetHost string // The host that was targeted
MoreSpecificResources []string // Child resource names that exist on this host
MoreSpecificIDs []string // Canonical resource IDs (kind:scope:id) for future ID-based targeting
ChildKinds []string // Resource kinds of children (for telemetry/routing: "system-container", "vm", etc.)
Message string // Human-readable message
}
func (e *ErrRoutingMismatch) Error() string {
return e.Message
}
// Code returns the error code for structured responses
func (e *ErrRoutingMismatch) Code() string {
return ErrCodeRoutingMismatch
}
// ToToolResponse returns a consistent ToolResponse for routing mismatches.
func (e *ErrRoutingMismatch) ToToolResponse() ToolResponse {
details := map[string]interface{}{
"target_host": e.TargetHost,
"more_specific_resources": e.MoreSpecificResources,
}
// Include canonical IDs as facts for the model. Do not pick one as the
// next tool target; the selected model owns that decision.
if len(e.MoreSpecificIDs) > 0 {
details["more_specific_resource_ids"] = e.MoreSpecificIDs
details["policy_boundary"] = "A more specific child resource exists on the requested host; choose the intended resource before retrying a scoped action."
} else {
details["policy_boundary"] = "A more specific child resource exists on the requested host; choose the intended resource before retrying a scoped action."
}
return NewToolBlockedError(
ErrCodeRoutingMismatch,
e.Message,
details,
)
}
// isStrictResolutionEnabled returns true if hard validation is enabled for write operations.
// Set PULSE_STRICT_RESOLUTION=true to block write operations on undiscovered resources.
func isStrictResolutionEnabled() bool {
val := os.Getenv("PULSE_STRICT_RESOLUTION")
return val == "true" || val == "1" || val == "yes"
}
// isWriteAction returns true if the action is a write/mutating operation.
// Note: "exec" is treated as write because it can execute arbitrary commands.
// For finer control, use classifyCommandRisk() to distinguish read-only exec commands.
func isWriteAction(action string) bool {
writeActions := map[string]bool{
"start": true,
"stop": true,
"restart": true,
"delete": true,
"shutdown": true,
"exec": true,
"write": true,
"append": true,
}
return writeActions[action]
}
// CommandRisk represents the risk level of a shell command
type CommandRisk int
const (
CommandRiskReadOnly CommandRisk = 0 // Safe read-only commands
CommandRiskLowWrite CommandRisk = 1 // Low-risk writes (touch, mkdir temp)
CommandRiskMediumWrite CommandRisk = 2 // Medium-risk writes (config changes)
CommandRiskHighWrite CommandRisk = 3 // High-risk writes (rm, systemctl, package managers)
)
// ExecutionIntent represents whether a command can be proven non-mutating.
// This is the primary abstraction for pulse_read gating decisions.
//
// Invariant: pulse_read may execute commands that are provably non-mutating
// either by construction (known read-only commands) or by bounded inspection
// (self-contained input + no shell composition + no write patterns). Any command
// that depends on external input, shell composition, or ambiguous semantics is
// treated as write-capable and blocked from pulse_read.
type ExecutionIntent int
const (
// IntentReadOnlyCertain - command is non-mutating by construction.
// Examples: ls, cat, grep, docker logs, ffprobe, kubectl get
// These cannot mutate regardless of arguments.
IntentReadOnlyCertain ExecutionIntent = iota
// IntentReadOnlyConditional - command appears read-only by bounded inspection.
// The command is self-contained (no shell composition) and content inspection
// found no write patterns. Examples: sqlite3 "SELECT ...", psql -c "SELECT ..."
// Guardrails: no redirects, no pipes, no subshells, no chaining, inline input only.
IntentReadOnlyConditional
// IntentWriteOrUnknown - command may mutate or cannot be proven safe.
// Either it matches known write patterns, has shell composition that prevents
// analysis, or is unknown and we fail closed.
IntentWriteOrUnknown
)
// IntentResult contains the execution intent classification and the reason for it.
type IntentResult struct {
Intent ExecutionIntent
Reason string // Human-readable reason for classification
NonInteractiveBlock *NonInteractiveBlockResult // Non-nil if blocked by NonInteractiveOnly guardrail
}
type executionClassificationOptions struct {
TimeoutBounded bool
}
// ContentInspector examines command content to determine if it's read-only.
// Different inspectors handle different tool families (SQL, Redis, kubectl, etc.)
type ContentInspector interface {
// Applies returns true if this inspector handles the given command
Applies(cmdLower string) bool
// IsReadOnly returns (true, "") if content is read-only, or (false, reason) if not
IsReadOnly(cmdLower string) (bool, string)
}
// sqlContentInspector handles SQL CLI tools (sqlite3, mysql, psql, etc.)
type sqlContentInspector struct{}
func (s *sqlContentInspector) Applies(cmdLower string) bool {
sqlCLIs := []string{"sqlite3 ", "mysql ", "mariadb ", "psql ", "mycli ", "pgcli ", "litecli "}
for _, cli := range sqlCLIs {
if strings.Contains(cmdLower, cli) || strings.HasPrefix(cmdLower, strings.TrimSuffix(cli, " ")) {
return true
}
}
return false
}
func (s *sqlContentInspector) IsReadOnly(cmdLower string) (bool, string) {
// SQL statements that mutate data or schema.
// Conservative: includes DDL, DML writes, transaction control, and admin commands.
sqlWriteKeywords := []string{
// DML writes
"insert ", "update ", "delete ", "replace ",
// DDL
"create ", "drop ", "alter ", "truncate ",
"merge ", "upsert ",
// Transaction control (expands attack surface)
"begin", "commit", "rollback", "savepoint", "release ",
// Database management
"attach ", "detach ",
"vacuum", "reindex",
"grant ", "revoke ",
"pragma ",
}
for _, kw := range sqlWriteKeywords {
if strings.Contains(cmdLower, kw) {
return false, fmt.Sprintf("SQL contains write/control keyword: %s", strings.TrimSpace(kw))
}
}
// Conservative: if we can't find inline SQL content, assume external input
hasInlineSQL := strings.Contains(cmdLower, `"`) ||
strings.Contains(cmdLower, `'`) ||
strings.Contains(cmdLower, " .") || // dot commands like .tables, .schema
strings.Contains(cmdLower, " -e ") || // mysql -e
strings.Contains(cmdLower, " -c ") // psql -c
if !hasInlineSQL {
return false, "no inline SQL found; input may be external (piped/interactive)"
}
return true, ""
}
// registeredInspectors is the list of content inspectors to try.
// Add new inspectors here for redis-cli, kubectl, etc.
var registeredInspectors = []ContentInspector{
&sqlContentInspector{},
// Future: &redisContentInspector{},
// Future: &kubectlContentInspector{},
}
// ClassifyExecutionIntent determines whether a command can be proven non-mutating.
// This is the main entry point for pulse_read gating decisions.
func ClassifyExecutionIntent(command string) IntentResult {
// === PHASE 0: Shell chaining decomposition ===
// If the command contains ;, &&, or || outside quotes, split into
// sub-commands and classify each independently. If ALL are read-only,
// the whole chain is read-only. If ANY is write/unknown, block.
if hasShellChainingOutsideQuotes(command) {
return classifyChainedCommand(command)
}
return classifySingleCommand(command)
}
// classifySingleCommand classifies a single (non-chained) command.
func classifySingleCommand(command string) IntentResult {
return classifySingleCommandWithOptions(command, executionClassificationOptions{})
}
func classifySingleCommandWithOptions(command string, opts executionClassificationOptions) IntentResult {
cmdLower := strings.ToLower(command)
// === PHASE 1: Mutation-capability guards ===
// These make ANY command potentially dangerous regardless of the binary.
// Includes: sudo, redirects, tee, subshells, pipes
if reason := checkMutationCapabilityGuards(command, cmdLower); reason != "" {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: reason}
}
if result, handled := classifyTimeoutWrapper(command); handled {
return result
}
// === PHASE 1.5: NonInteractiveOnly guardrails ===
// MUST be checked before Phase 3 (read-only by construction) because even
// read-only commands like `tail -f` and `journalctl -f` can hang indefinitely.
// pulse_read requires commands that terminate deterministically.
if niBlock := checkNonInteractiveGuardrails(command, cmdLower, opts); niBlock != nil {
return IntentResult{
Intent: IntentWriteOrUnknown,
Reason: niBlock.Message,
NonInteractiveBlock: niBlock,
}
}
// === PHASE 2: Known write patterns ===
// Check BEFORE read-only patterns to catch write variants like "sed -i"
// before generic patterns like "sed " match.
if reason := matchesWritePatterns(command, cmdLower); reason != "" {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: reason}
}
if result, handled := classifyCurlCommand(command); handled {
return result
}
if result, handled := classifyEnvCommand(command); handled {
return result
}
if result, handled := classifyFindCommand(command); handled {
return result
}
if result, handled := classifyWgetCommand(command); handled {
return result
}
// === PHASE 3: Known read-only by construction ===
// Commands that cannot mutate regardless of arguments.
// Only reached if Phase 2 didn't match any write patterns.
if isReadOnlyByConstruction(cmdLower) {
return IntentResult{Intent: IntentReadOnlyCertain, Reason: "known read-only command"}
}
// === PHASE 4: Self-contained read candidate check ===
// Additional guardrails before content inspection.
if reason := checkSelfContainedGuardrails(command, cmdLower); reason != "" {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: reason}
}
// === PHASE 5: Content inspection via registered inspectors ===
for _, inspector := range registeredInspectors {
if inspector.Applies(cmdLower) {
if isReadOnly, reason := inspector.IsReadOnly(cmdLower); isReadOnly {
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "content inspection: read-only"}
} else {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "content inspection: " + reason}
}
}
}
// === PHASE 6: Fail closed ===
// Unknown commands may wrap interpreters, scripts, or side-effecting tools.
// pulse_read must only execute commands that are known read-only by construction
// or proven read-only by an explicit content inspector.
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "unknown command is not on the read-only allowlist"}
}
func classifyTimeoutWrapper(command string) (IntentResult, bool) {
fields := strings.Fields(command)
if len(fields) == 0 || strings.ToLower(commandTokenBase(fields[0])) != "timeout" {
return IntentResult{}, false
}
innerCommand, ok := timeoutInnerCommand(fields)
if !ok {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "timeout wrapper must include a duration and command"}, true
}
innerResult := classifySingleCommandWithOptions(innerCommand, executionClassificationOptions{TimeoutBounded: true})
if innerResult.Intent == IntentReadOnlyCertain || innerResult.Intent == IntentReadOnlyConditional {
return IntentResult{Intent: innerResult.Intent, Reason: "timeout-bounded " + innerResult.Reason}, true
}
return IntentResult{
Intent: IntentWriteOrUnknown,
Reason: "timeout-wrapped command not read-only: " + innerResult.Reason,
NonInteractiveBlock: innerResult.NonInteractiveBlock,
}, true
}
func timeoutInnerCommand(fields []string) (string, bool) {
if len(fields) < 3 {
return "", false
}
i := 1
for i < len(fields) {
field := fields[i]
switch {
case field == "--":
i++
goto duration
case field == "--preserve-status" || field == "--foreground" || field == "--verbose" || field == "-v":
i++
continue
case field == "-s" || field == "--signal" || field == "-k" || field == "--kill-after":
i += 2
continue
case strings.HasPrefix(field, "--signal=") || strings.HasPrefix(field, "--kill-after="):
i++
continue
case strings.HasPrefix(field, "-s") && len(field) > 2:
i++
continue
case strings.HasPrefix(field, "-k") && len(field) > 2:
i++
continue
case strings.HasPrefix(field, "-"):
return "", false
default:
goto duration
}
}
duration:
if i >= len(fields) || !isTimeoutDurationToken(fields[i]) {
return "", false
}
i++
if i >= len(fields) {
return "", false
}
return strings.Join(fields[i:], " "), true
}
func isTimeoutDurationToken(token string) bool {
if token == "" {
return false
}
seenDigit := false
seenDot := false
for i, r := range token {
if r >= '0' && r <= '9' {
seenDigit = true
continue
}
if r == '.' && !seenDot {
seenDot = true
continue
}
suffix := token[i:]
return seenDigit && (suffix == "s" || suffix == "m" || suffix == "h" || suffix == "d")
}
return seenDigit
}
func classifyEnvCommand(command string) (IntentResult, bool) {
fields := strings.Fields(strings.ToLower(command))
if len(fields) == 0 || fields[0] != "env" {
return IntentResult{}, false
}
if envCommandHasUtility(fields) {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "env can execute wrapped commands and is not read-only by construction"}, true
}
return IntentResult{Intent: IntentReadOnlyCertain, Reason: "bare env command prints environment only"}, true
}
func envCommandHasUtility(fields []string) bool {
for i := 1; i < len(fields); i++ {
field := strings.Trim(fields[i], `"'`)
switch {
case field == "--":
return i+1 < len(fields)
case field == "-0" || field == "-i" || field == "-" ||
field == "--ignore-environment" || field == "--null" ||
field == "--help" || field == "--version":
continue
case field == "-u" || field == "--unset" || field == "--chdir":
i++
continue
case strings.HasPrefix(field, "--unset=") || strings.HasPrefix(field, "--chdir="):
continue
case field == "-s" || field == "--split-string" || strings.HasPrefix(field, "--split-string="):
return true
case strings.Contains(field, "=") && !strings.HasPrefix(field, "="):
continue
default:
return true
}
}
return false
}
func classifyFindCommand(command string) (IntentResult, bool) {
fields := strings.Fields(strings.ToLower(command))
if len(fields) == 0 || fields[0] != "find" {
return IntentResult{}, false
}
if findHasWriteOrExecAction(fields) {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "find action can execute commands or write files"}, true
}
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "find command without write or exec actions"}, true
}
func findHasWriteOrExecAction(fields []string) bool {
for _, field := range fields[1:] {
field = strings.Trim(field, `"'`)
if field == "-exec" || field == "-execdir" ||
field == "-ok" || field == "-okdir" ||
field == "-delete" ||
field == "-fprint" || field == "-fprint0" ||
field == "-fprintf" || field == "-fls" {
return true
}
}
return false
}
func classifyCurlCommand(command string) (IntentResult, bool) {
fields := strings.Fields(command)
if len(fields) == 0 || strings.ToLower(commandTokenBase(fields[0])) != "curl" {
return IntentResult{}, false
}
if isCurlMutationOrFileOutput(command) {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "curl option can mutate requests or write files"}, true
}
if curlHasHTTPURL(fields[1:]) {
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "curl HTTP(S) request without mutation or file-output options"}, true
}
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "curl command is not read-only without an explicit HTTP(S) URL"}, true
}
func curlHasHTTPURL(fields []string) bool {
for i := 0; i < len(fields); i++ {
token := strings.Trim(fields[i], `"'`)
tokenLower := strings.ToLower(token)
switch {
case strings.HasPrefix(tokenLower, "http://") || strings.HasPrefix(tokenLower, "https://"):
return true
case tokenLower == "--url" && i+1 < len(fields):
next := strings.ToLower(strings.Trim(fields[i+1], `"'`))
if strings.HasPrefix(next, "http://") || strings.HasPrefix(next, "https://") {
return true
}
case strings.HasPrefix(tokenLower, "--url="):
url := strings.TrimPrefix(tokenLower, "--url=")
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return true
}
}
}
return false
}
func classifyWgetCommand(command string) (IntentResult, bool) {
fields := strings.Fields(command)
if len(fields) == 0 || strings.ToLower(commandTokenBase(fields[0])) != "wget" {
return IntentResult{}, false
}
hasSpider := false
for _, field := range fields[1:] {
token := strings.Trim(field, `"'`)
tokenLower := strings.ToLower(token)
if tokenLower == "--spider" {
hasSpider = true
continue
}
if isWgetWriteOrMutationOption(token, tokenLower) {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "wget option can write files or send mutation requests"}, true
}
}
if hasSpider {
return IntentResult{Intent: IntentReadOnlyConditional, Reason: "wget spider check is read-only"}, true
}
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "wget can write downloaded content and is not on the read-only allowlist unless --spider is used"}, true
}
func isWgetWriteOrMutationOption(token, tokenLower string) bool {
switch {
case token == "-O" || strings.HasPrefix(token, "-O") ||
tokenLower == "--output-document" || strings.HasPrefix(tokenLower, "--output-document="):
return true
case token == "-o" || strings.HasPrefix(token, "-o") ||
token == "-a" || strings.HasPrefix(token, "-a") ||
tokenLower == "--output-file" || strings.HasPrefix(tokenLower, "--output-file=") ||
tokenLower == "--append-output" || strings.HasPrefix(tokenLower, "--append-output="):
return true
case token == "-P" || strings.HasPrefix(token, "-P") ||
tokenLower == "--directory-prefix" || strings.HasPrefix(tokenLower, "--directory-prefix="):
return true
case tokenLower == "--post-data" || strings.HasPrefix(tokenLower, "--post-data=") ||
tokenLower == "--post-file" || strings.HasPrefix(tokenLower, "--post-file=") ||
tokenLower == "--body-data" || strings.HasPrefix(tokenLower, "--body-data=") ||
tokenLower == "--body-file" || strings.HasPrefix(tokenLower, "--body-file="):
return true
case tokenLower == "--method" || strings.HasPrefix(tokenLower, "--method="):
return true
case tokenLower == "--warc-file" || strings.HasPrefix(tokenLower, "--warc-file="):
return true
default:
return false
}
}
// checkMutationCapabilityGuards checks for shell patterns that enable mutation
// regardless of the underlying command. Returns reason if any guard fails.
//
// Also includes NonInteractiveOnly guardrails - pulse_read runs non-interactively,
// so commands requiring TTY or indefinite streaming are blocked.
func checkMutationCapabilityGuards(command, cmdLower string) string {
// sudo escalates any command
if strings.Contains(cmdLower, "sudo ") || strings.HasPrefix(cmdLower, "sudo") {
return "sudo escalates command privileges"
}
// Output redirection can overwrite files
if hasStdoutRedirect(command) {
return "output redirection can overwrite files"
}
if strings.Contains(cmdLower, " tee ") || strings.Contains(cmdLower, "|tee ") {
return "tee can write to files"
}
// Subshell/command substitution can execute arbitrary commands
if strings.Contains(command, "$(") || strings.Contains(command, "`") {
return "command substitution can execute arbitrary commands"
}
// Input redirection means we can't inspect the content.
// This catches: < (redirect), << (heredoc), <<< (here-string)
// But NOT stderr-to-stdout redirections like 2>&1 which are harmless.
// Examples blocked:
// sqlite3 db < script.sql
// psql <<EOF ... EOF
// sqlite3 db <<< "SELECT ..."
if hasInputRedirect(command) {
return "input redirection prevents content inspection"
}
// Pipes: only block when piping to a dual-use tool that interprets input
// (like SQL CLIs). Piping to read-only filters (grep, head, etc.) is safe.
if strings.Contains(command, "|") && !strings.Contains(command, "||") {
if pipedToDualUseTool(cmdLower) {
return "piped input to dual-use tool prevents content inspection"
}
}
// Note: shell chaining (;, &&, ||) is handled at the top level of
// ClassifyExecutionIntent via classifyChainedCommand, not here.
return ""
}
// NonInteractiveBlockResult contains structured policy facts about a NonInteractiveOnly block.
type NonInteractiveBlockResult struct {
Category string // telemetry category: tty_flag, pager, unbounded_stream, interactive_repl
Message string // human-readable reason
}
// checkNonInteractiveGuardrails enforces the exit-boundedness invariant:
// pulse_read must only execute commands that terminate deterministically.
//
// Categories (for telemetry labels):
// - tty_flag: interactive TTY flags (-it, --tty)
// - pager: pager/editor tools (less, vim, nano)
// - unbounded_stream: follow mode without bounds (-f without -n/--since/timeout)
// - interactive_repl: commands that open REPL/shell without non-interactive flags
//
// Returns nil if command passes all guardrails.
func checkNonInteractiveGuardrails(command, cmdLower string, opts executionClassificationOptions) *NonInteractiveBlockResult {
// [tty_flag] Interactive TTY flags allocate a terminal
if hasInteractiveTTYFlags(cmdLower) {
return &NonInteractiveBlockResult{
Category: "tty_flag",
Message: "[tty_flag] interactive/TTY flags require terminal; pulse_read requires bounded non-interactive commands",
}
}
// [pager] Pager and editor tools require terminal interaction
if isPagerOrEditorTool(cmdLower) {
return &NonInteractiveBlockResult{
Category: "pager",
Message: "[pager] pager/editor tools require terminal; pulse_read requires bounded non-interactive commands",
}
}
// [unbounded_stream] Live monitoring tools never terminate
if isLiveMonitoringTool(cmdLower) {
return &NonInteractiveBlockResult{
Category: "unbounded_stream",
Message: "[unbounded_stream] live monitoring tools run indefinitely; pulse_read requires commands that terminate deterministically",
}
}
// [unbounded_stream] Follow mode without explicit bound
if !opts.TimeoutBounded && isUnboundedStreaming(cmdLower) {
return &NonInteractiveBlockResult{
Category: "unbounded_stream",
Message: "[unbounded_stream] follow mode without bound; pulse_read requires commands that terminate deterministically",
}
}
// [interactive_repl] Commands that open REPL/interactive session
if isInteractiveREPL(cmdLower) {
return &NonInteractiveBlockResult{
Category: "interactive_repl",
Message: "[interactive_repl] command opens interactive session; pulse_read requires bounded non-interactive commands",
}
}
return nil
}
// hasInteractiveTTYFlags detects flags that request interactive/TTY mode.
func hasInteractiveTTYFlags(cmdLower string) bool {
// Only check for docker/kubectl commands
isDockerKubectl := strings.HasPrefix(cmdLower, "docker ") ||
strings.HasPrefix(cmdLower, "kubectl ")
if !isDockerKubectl {
return false
}
// Docker/kubectl -it or -i -t combinations (common shorthand)
if strings.Contains(cmdLower, " -it ") || strings.Contains(cmdLower, " -it\t") ||
strings.HasSuffix(cmdLower, " -it") ||
strings.Contains(cmdLower, " -ti ") || strings.Contains(cmdLower, " -ti\t") ||
strings.HasSuffix(cmdLower, " -ti") {
return true
}
// Explicit long flags
if strings.Contains(cmdLower, " --tty") || strings.Contains(cmdLower, " --interactive") {
return true
}
// Check for standalone -t and -i flags that aren't part of other patterns
// Avoid matching: 2>&1 (stderr redirect), -t tablename, etc.
// Look for " -t " or " -i " as standalone flags followed by non-alphanumeric
parts := strings.Fields(cmdLower)
for i, part := range parts {
if part == "-t" || part == "-i" {
// Found standalone -t or -i flag
// Check if this is in the context of exec/run subcommands
for j := 0; j < i; j++ {
if parts[j] == "exec" || parts[j] == "run" {
return true
}
}
}
}
return false
}
// isPagerOrEditorTool detects pager and editor tools that require terminal interaction.
func isPagerOrEditorTool(cmdLower string) bool {
// Extract first word
firstWord := cmdLower
if spaceIdx := strings.Index(cmdLower, " "); spaceIdx > 0 {
firstWord = cmdLower[:spaceIdx]
}
pagerEditorTools := []string{"less", "more", "vim", "vi", "nano", "emacs", "pico", "ed"}
for _, tool := range pagerEditorTools {
if firstWord == tool {
return true
}
}
return false
}
// isLiveMonitoringTool detects tools that run indefinitely showing live data.
func isLiveMonitoringTool(cmdLower string) bool {
firstWord := cmdLower
if spaceIdx := strings.Index(cmdLower, " "); spaceIdx > 0 {
firstWord = cmdLower[:spaceIdx]
}
// These tools run until interrupted
liveTools := []string{"top", "htop", "atop", "iotop", "iftop", "nload", "watch"}
for _, tool := range liveTools {
if firstWord == tool {
return true
}
}
return false
}
// isUnboundedStreaming detects follow-mode commands without an exit bound.
// Exit-bounded = terminates deterministically (line count, time window, or timeout wrapper).
//
// Allowed (exit-bounded):
// - journalctl -n 100, tail -n 50, tail -100 -f, kubectl logs --tail=100
// - journalctl --since "10 min ago", kubectl logs --since=10m
// - timeout 5s tail -f
//
// Blocked (runs indefinitely):
// - journalctl -f, tail -f, kubectl logs -f, dmesg -w
func isUnboundedStreaming(cmdLower string) bool {
// Only certain commands support follow mode - don't flag -f on other commands
// (e.g., "hostname -f" uses -f for "full", not "follow")
streamingCommands := []string{"tail ", "journalctl ", "docker logs ", "kubectl logs ", "dmesg "}
isStreamingCmd := false
for _, prefix := range streamingCommands {
if strings.HasPrefix(cmdLower, prefix) {
isStreamingCmd = true
break
}
}
if !isStreamingCmd {
return false
}
// Check for follow flags
hasFollowFlag := strings.Contains(cmdLower, " -f") ||
strings.Contains(cmdLower, " --follow") ||
strings.Contains(cmdLower, " -w") // dmesg uses -w/--follow
if !hasFollowFlag {
return false
}
// If wrapped in timeout, it's exit-bounded
if strings.HasPrefix(cmdLower, "timeout ") {
return false
}
// Check for explicit bounds that make it exit-bounded:
// - Line count: -n, --lines, --tail
// - Time window: --since, --until (journalctl/kubectl logs)
hasBound := strings.Contains(cmdLower, " -n ") ||
strings.Contains(cmdLower, " -n=") ||
strings.Contains(cmdLower, " --lines") ||
strings.Contains(cmdLower, " --tail=") ||
strings.Contains(cmdLower, " --tail ") ||
strings.Contains(cmdLower, " --since") || // journalctl --since "10 min ago", kubectl logs --since=10m
strings.Contains(cmdLower, " --until") || // journalctl --until "2024-01-01"
hasTailShorthandBound(cmdLower) // tail -100 shorthand
// Follow flag without bounds = runs indefinitely
return !hasBound
}
// hasTailShorthandBound checks for tail's -N shorthand (e.g., tail -100 -f)
func hasTailShorthandBound(cmdLower string) bool {
if !strings.HasPrefix(cmdLower, "tail ") {
return false
}
// Look for -NUMBER pattern (tail's shorthand for -n NUMBER)
// Match patterns like: tail -100, tail -50 -f
parts := strings.Fields(cmdLower)
for _, part := range parts {
if len(part) >= 2 && part[0] == '-' {
// Check if rest is digits
allDigits := true
for _, c := range part[1:] {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if allDigits && len(part) > 1 {
return true
}
}
}
return false
}
// isInteractiveREPL detects commands that open an interactive REPL/shell
// unless given explicit non-interactive flags (-c, --execute, inline command).
//
// Blocked (opens REPL):
// - ssh host (no command)
// - mysql, psql, sqlite3 db (no -c/-e/inline SQL)
// - redis-cli (no command args)
// - python, node, irb
// - openssl s_client
//
// Allowed (non-interactive):
// - ssh host "command"
// - mysql -e "SELECT 1"
// - sqlite3 db "SELECT 1"
func isInteractiveREPL(cmdLower string) bool {
firstWord := cmdLower
if spaceIdx := strings.Index(cmdLower, " "); spaceIdx > 0 {
firstWord = cmdLower[:spaceIdx]
}
// SSH: interactive unless a command is provided after host
// ssh host -> interactive
// ssh host "ls -la" -> non-interactive
// ssh -t host -> interactive (explicit TTY)
if firstWord == "ssh" {
// If has -t flag, it's explicitly requesting TTY
if strings.Contains(cmdLower, " -t ") || strings.Contains(cmdLower, " -t") {
return true
}
// Count non-flag arguments after ssh
// ssh [options] host [command]
parts := strings.Fields(cmdLower)
nonFlagArgs := 0
skipNext := false
for i, part := range parts[1:] { // skip "ssh"
if skipNext {
skipNext = false
continue
}
// Skip flags that take arguments
if part == "-i" || part == "-l" || part == "-p" || part == "-o" || part == "-F" {
skipNext = true
continue
}
// Skip other flags
if strings.HasPrefix(part, "-") {
continue
}
nonFlagArgs++
// If we have more than just the host, there's a command
if nonFlagArgs > 1 || (nonFlagArgs == 1 && i < len(parts)-2) {
return false // has command, not interactive
}
}
// Only host, no command = interactive
return nonFlagArgs <= 1
}
// SQL CLIs: handled by sqlContentInspector, but catch bare invocations
// mysql, psql without -c/-e, sqlite3 without inline SQL
if firstWord == "mysql" || firstWord == "mariadb" {
// Non-interactive if has -e or --execute
if strings.Contains(cmdLower, " -e ") || strings.Contains(cmdLower, " -e\"") ||
strings.Contains(cmdLower, " --execute") {
return false
}
// Non-interactive if has piped input (handled elsewhere, but check)
if strings.Contains(cmdLower, " < ") || strings.Contains(cmdLower, " <<") {
return false
}
return true // bare mysql = interactive
}
if firstWord == "psql" {
// Non-interactive if has -c or --command
if strings.Contains(cmdLower, " -c ") || strings.Contains(cmdLower, " -c\"") ||
strings.Contains(cmdLower, " --command") {
return false
}
if strings.Contains(cmdLower, " < ") || strings.Contains(cmdLower, " <<") {
return false
}
return true
}
// redis-cli: interactive without command arguments
if firstWord == "redis-cli" {
// Check for command after connection flags
parts := strings.Fields(cmdLower)
hasCommand := false
skipNext := false
for _, part := range parts[1:] {
if skipNext {
skipNext = false
continue
}
// Connection flags that take arguments
if part == "-h" || part == "-p" || part == "-a" || part == "-n" || part == "--user" {
skipNext = true
continue
}
if strings.HasPrefix(part, "-") {
continue
}
// Non-flag argument = Redis command
hasCommand = true
break
}
return !hasCommand
}
// Scripting language runtimes are dual-use. Bare invocations are also
// interactive; non-interactive forms are blocked by the fail-closed
// read-only allowlist boundary.
if firstWord == "python" || firstWord == "python3" || firstWord == "python2" {
// Non-interactive if has -c or script file
if strings.Contains(cmdLower, " -c ") || strings.Contains(cmdLower, " -c\"") {
return false
}
// Check for script file (non-flag argument)
parts := strings.Fields(cmdLower)
for _, part := range parts[1:] {
if !strings.HasPrefix(part, "-") && !strings.HasPrefix(part, "\"") {
return false // has script file
}
}
return true // bare python = REPL
}
if firstWord == "node" || firstWord == "nodejs" {
// Non-interactive if has -e or script file
if strings.Contains(cmdLower, " -e ") || strings.Contains(cmdLower, " -e\"") ||
strings.Contains(cmdLower, " --eval") {
return false
}
parts := strings.Fields(cmdLower)
for _, part := range parts[1:] {
if !strings.HasPrefix(part, "-") {
return false // has script file
}
}
return true
}
if firstWord == "irb" || firstWord == "pry" {
// Ruby REPLs - almost always interactive
// Non-interactive only with -e
if strings.Contains(cmdLower, " -e ") {
return false
}
return true
}
// openssl s_client is always interactive (waits for input)
if strings.HasPrefix(cmdLower, "openssl s_client") || strings.HasPrefix(cmdLower, "openssl s_server") {
return true
}
return false
}
// hasStdoutRedirect checks for dangerous output redirects while allowing safe stderr redirects.
// hasInputRedirect checks for input redirection (<, <<, <<<) while
// allowing harmless stderr-to-stdout redirections (2>&1, 2<&1).
func hasInputRedirect(command string) bool {
if !strings.Contains(command, "<") {
return false
}
// Remove safe fd-merge patterns before checking for input redirects.
// 2>&1 = merge stderr into stdout; 2<&1 = merge stderr into stdout (rare)
// These contain '<' but aren't input redirection.
cmd := command
cmd = strings.ReplaceAll(cmd, "2>&1", "")
cmd = strings.ReplaceAll(cmd, "2<&1", "")
cmd = strings.ReplaceAll(cmd, "1>&2", "")
cmd = strings.ReplaceAll(cmd, "&>", "") // bash shorthand for > ... 2>&1
return strings.Contains(cmd, "<")
}
func hasStdoutRedirect(command string) bool {
if !strings.Contains(command, ">") {
return false
}
// Remove safe stderr redirects before checking
cmd := strings.ReplaceAll(command, "2>/dev/null", "")
cmd = strings.ReplaceAll(cmd, "2>&1", "")
return strings.Contains(cmd, ">")
}
// pipedToDualUseTool checks if a piped command sends input to a dual-use tool
// that could interpret piped input dangerously (like SQL CLIs).
// Piping to read-only filters (grep, head, tail, etc.) is safe.
func pipedToDualUseTool(cmdLower string) bool {
// Find the last pipe (not ||)
pipeIdx := -1
for i := 0; i < len(cmdLower)-1; i++ {
if cmdLower[i] == '|' && cmdLower[i+1] != '|' {
pipeIdx = i
}
}
if pipeIdx == -1 {
return false
}
// Get the command after the last pipe
afterPipe := strings.TrimSpace(cmdLower[pipeIdx+1:])
// Dual-use tools that interpret piped input dangerously
dualUseTools := []string{
"sqlite3", "mysql", "mariadb", "psql", "mycli", "pgcli", "litecli",
"redis-cli", "mongo", "mongosh",
"sh ", "sh\t", "bash ", "bash\t", "zsh ", "zsh\t",
"python", "perl", "ruby", "node",
"awk", "sed", "env", "timeout", "xargs",
}
for _, tool := range dualUseTools {
if strings.HasPrefix(afterPipe, tool) {
return true
}
}
return false
}
// checkSelfContainedGuardrails verifies the command is a single execution unit.
// Returns reason if any guardrail fails.
// Note: Most guardrails have been moved to checkMutationCapabilityGuards (Phase 1)
// to ensure they run before read-only-by-construction checks.
func checkSelfContainedGuardrails(command, cmdLower string) string {
// Most checks are now in Phase 1 (checkMutationCapabilityGuards)
// This phase is kept for potential future guardrails that should run
// after write pattern matching.
return ""
}
// isReadOnlyByConstruction returns true for commands that cannot mutate by design.
// Only matches patterns at the START of the command to avoid false positives
// (e.g., "date " inside "UPDATE" SQL statements).
func isReadOnlyByConstruction(cmdLower string) bool {
// Note: Pager tools (less, more) and live monitors (top, htop) are excluded here
// because they're blocked by NonInteractiveOnly guardrails in Phase 1.
readOnlyCommands := []string{
"cat", "head", "tail",
"ls", "ll", "dir",
"ps", "free", "df", "du", "iostat", "vmstat", "mpstat", "sar",
"grep", "locate", "which", "whereis",
"journalctl", "dmesg",
"uname", "hostname", "whoami", "id", "groups",
"echo", "printf",
"date", "uptime", "printenv", "locale",
"netstat", "ss", "ifconfig", "route", "arp",
"ping", "traceroute", "tracepath", "nslookup", "dig", "host",
"file", "stat", "wc", "sort", "uniq", "cut", "tr",
"lsof", "fuser",
"getent", "nproc", "lscpu", "lsmem", "lsblk", "blkid",
"lxc-ls", "lxc-info",
"zcat", "zgrep", "bzcat", "xzcat",
"md5sum", "sha256sum", "sha1sum",
"test",
// Process inspection
"pgrep", "pidof", "pstree",
// Login/session info
"last", "lastlog", "who", "w",
// Hardware inspection
"lspci", "lsusb", "dmidecode", "hwinfo", "inxi",
"sensors", "hddtemp", "smartctl", "nvme",
// Media inspection tools
"ffprobe", "mediainfo", "exiftool",
// Proxmox version
"pveversion",
}
// Multi-word patterns that must appear at the start
multiWordPatterns := []string{
// Curl read-only variants (various flag combinations)
"curl -s", "curl --silent", "curl -i", "curl --head",
"curl -k", "curl --insecure",
"curl -sk", "curl -ks", "curl -ki", "curl -ik",
"curl http", "curl https",
// Docker read-only
"docker ps", "docker logs", "docker inspect", "docker stats",
"docker images", "docker info", "docker version",
"docker top", "docker port",
"docker network ls", "docker network inspect",
"docker volume ls", "docker volume inspect",
"docker-compose ps", "docker compose ps",
// Systemd read-only
"systemctl status", "systemctl is-active", "systemctl is-enabled",
"systemctl list", "systemctl show",
"service status", "service --status-all",
// Proxmox read-only
"pct list", "pct status", "pct config", "pct df",
"qm list", "qm status", "qm config", "qm guest cmd",
"pvesh get",
"pvecm status", "pvecm nodes",
"pvesm status", "pvesm list",
// ZFS/ZPool read-only
"zpool status", "zpool list", "zpool get",
"zfs list", "zfs get",
// RAID inspection
"mdadm --detail", "mdadm -D",
// Network: ip with optional protocol flags (-4, -6)
"ip addr", "ip route", "ip link", "ip neigh", "ip neighbor",
"ip -4 addr", "ip -4 route", "ip -4 link", "ip -4 neigh", "ip -4 neighbor",
"ip -6 addr", "ip -6 route", "ip -6 link", "ip -6 neigh", "ip -6 neighbor",
"ip a", "ip r", "ip n",
// Package info (read-only)
"apt list", "apt show", "apt-cache",
"dpkg -l", "dpkg --list", "dpkg -s",
"rpm -q", "rpm -qa",
"yum list", "dnf list",
// Kubectl read-only commands
"kubectl get", "kubectl describe", "kubectl logs", "kubectl top", "kubectl cluster-info",
"kubectl api-resources", "kubectl api-versions", "kubectl version", "kubectl config view",
// Network connectivity checks (zero-I/O port scan, read-only)
"nc -z", "nc -vz", "nc -zv", "nc -zw", "nc -zvw",
"netcat -z", "netcat -zv",
}
// Extract first word of command
firstWord := cmdLower
if spaceIdx := strings.Index(cmdLower, " "); spaceIdx > 0 {
firstWord = cmdLower[:spaceIdx]
}
// Check single-word commands
for _, cmd := range readOnlyCommands {
if firstWord == cmd {
return true
}
}
// Check multi-word patterns at start
for _, pattern := range multiWordPatterns {
if strings.HasPrefix(cmdLower, pattern) {
return true
}
}
// Special case: nc/netcat with -z flag anywhere (zero-I/O mode is read-only regardless of flag order)
if (firstWord == "nc" || firstWord == "netcat") && containsFlag(cmdLower, "-z") {
return true
}
// Special case: [ (test shorthand)
if strings.HasPrefix(cmdLower, "[ ") {
return true
}
return false
}
// containsFlag checks whether a flag (e.g. "-z") appears as a standalone
// token or embedded in a combined short-flag group (e.g. "-zv", "-zvw") in cmd.
func containsFlag(cmd, flag string) bool {
// flag is expected to be like "-z"
char := strings.TrimPrefix(flag, "-")
for _, field := range strings.Fields(cmd) {
if field == flag {
return true
}
// Check combined flags like "-zv", "-zvw", "-wzv"
if strings.HasPrefix(field, "-") && !strings.HasPrefix(field, "--") && strings.Contains(field[1:], char) {
return true
}
}
return false
}
// matchesWritePatterns checks for known write-capable command patterns.
// Returns reason if a write pattern matches.
func matchesWritePatterns(command, cmdLower string) string {
// High-risk patterns
highRiskPatterns := map[string]string{
"rm ": "file deletion", "rm\t": "file deletion", "rmdir": "directory deletion",
"shutdown": "system shutdown", "reboot": "system reboot", "poweroff": "system poweroff", "halt": "system halt",
"systemctl restart": "service restart", "systemctl stop": "service stop", "systemctl start": "service start",
"systemctl enable": "service enable", "systemctl disable": "service disable",
"init ": "init control",
"apt ": "package management", "apt-get ": "package management", "yum ": "package management",
"dnf ": "package management", "pacman ": "package management", "apk ": "package management", "brew ": "package management",
"pip install": "package install", "pip uninstall": "package uninstall",
"npm install": "package install", "npm uninstall": "package uninstall", "cargo install": "package install",
"docker rm": "container removal", "docker stop": "container stop", "docker kill": "container kill",
"docker restart": "container restart", "docker exec": "container exec", "kubectl exec": "container exec",
"kill ": "process termination", "killall ": "process termination", "pkill ": "process termination",
"dd ": "disk write", "mkfs": "filesystem creation", "fdisk": "disk partition", "parted": "disk partition", "mkswap": "swap creation",
"iptables": "firewall modification", "firewall-cmd": "firewall modification", "ufw ": "firewall modification",
"truncate": "file truncation",
"chmod ": "permission change", "chown ": "ownership change", "chgrp ": "group change",
"useradd": "user creation", "userdel": "user deletion", "usermod": "user modification",
"chpasswd": "password change",
"crontab -e": "cron edit", "crontab -r": "cron removal", "crontab -": "cron modification",
"visudo": "sudoers edit", "vipw": "passwd edit",
"mount ": "filesystem mount", "umount ": "filesystem unmount",
"modprobe": "kernel module", "rmmod": "kernel module removal", "insmod": "kernel module insertion",
"sysctl -w": "kernel parameter change",
"nc -l": "network listener",
}
for pattern, reason := range highRiskPatterns {
if strings.Contains(cmdLower, pattern) {
return reason
}
}
// Command-start-only patterns: these must be the first word to avoid matching
// substrings in arguments (e.g., "pve-daily-utils.service" contains "service",
// "grep /etc/passwd" contains "passwd").
if strings.HasPrefix(cmdLower, "service ") {
return "service control"
}
if strings.HasPrefix(cmdLower, "passwd") {
return "password change"
}
// Medium-risk patterns
mediumRiskPatterns := map[string]string{
"mv ": "file move", "cp ": "file copy",
"sed -i": "in-place edit", "awk -i": "in-place edit",
"touch ": "file creation", "mkdir ": "directory creation",
// echo/printf: output redirection is already caught by Phase 1 (hasStdoutRedirect).
// Without a redirect, echo/printf are read-only (just print to stdout).
// They are now in the read-only allowlist in Phase 3.
"wget -O": "file download", "wget --output": "file download",
"tar -x": "archive extraction", "tar x": "archive extraction", "unzip ": "archive extraction", "gunzip ": "archive extraction",
}
for pattern, reason := range mediumRiskPatterns {
if strings.Contains(cmdLower, pattern) {
return reason
}
}
if containsCommandToken(cmdLower, "ln") || containsCommandToken(cmdLower, "link") {
return "link creation"
}
// Curl with mutation verbs
if isCurlMutationOrFileOutput(command) {
return "curl mutation or file-output request"
}
return ""
}
func isCurlMutationOrFileOutput(command string) bool {
if !containsCommandToken(strings.ToLower(command), "curl") {
return false
}
fields := strings.Fields(command)
for i, field := range fields {
if strings.ToLower(commandTokenBase(field)) != "curl" {
continue
}
for j := i + 1; j < len(fields); j++ {
token := strings.Trim(fields[j], `"'`)
tokenLower := strings.ToLower(token)
switch {
case token == "-X" || tokenLower == "--request":
if j+1 < len(fields) && isHTTPMutationMethod(strings.Trim(fields[j+1], `"'`)) {
return true
}
j++
case strings.HasPrefix(token, "-X") && len(token) > 2:
if isHTTPMutationMethod(token[2:]) {
return true
}
case strings.HasPrefix(tokenLower, "--request="):
if isHTTPMutationMethod(strings.TrimPrefix(tokenLower, "--request=")) {
return true
}
case token == "-d" || strings.HasPrefix(token, "-d") ||
strings.HasPrefix(tokenLower, "--data") || tokenLower == "--json":
return true
case token == "-F" || strings.HasPrefix(token, "-F") || strings.HasPrefix(tokenLower, "--form"):
return true
case token == "-T" || strings.HasPrefix(token, "-T") || strings.HasPrefix(tokenLower, "--upload"):
return true
case token == "-o" || strings.HasPrefix(token, "-o") ||
token == "-O" || strings.Contains(shortCurlOptionCluster(token), "o") || strings.Contains(shortCurlOptionCluster(token), "O") ||
tokenLower == "--output" || strings.HasPrefix(tokenLower, "--output=") ||
tokenLower == "--output-dir" || strings.HasPrefix(tokenLower, "--output-dir=") ||
tokenLower == "--remote-name" || tokenLower == "--remote-header-name":
return true
case token == "-c" || strings.HasPrefix(token, "-c") ||
tokenLower == "--cookie-jar" || strings.HasPrefix(tokenLower, "--cookie-jar="):
return true
case token == "-K" || strings.Contains(shortCurlOptionCluster(token), "K") ||
tokenLower == "--dump-header" || strings.HasPrefix(tokenLower, "--dump-header=") ||
tokenLower == "--config" || strings.HasPrefix(tokenLower, "--config="):
return true
case strings.Contains(shortCurlOptionCluster(token), "F") ||
strings.Contains(shortCurlOptionCluster(token), "T") ||
strings.Contains(shortCurlOptionCluster(token), "c"):
return true
}
}
}
return false
}
func shortCurlOptionCluster(token string) string {
if !strings.HasPrefix(token, "-") || strings.HasPrefix(token, "--") || len(token) < 3 {
return ""
}
if strings.HasPrefix(token, "-X") || strings.HasPrefix(token, "-d") {
return ""
}
return token[1:]
}
func commandTokenBase(field string) string {
field = strings.Trim(field, `"'`)
if idx := strings.LastIndex(field, "/"); idx >= 0 {
return field[idx+1:]
}
return field
}
func isHTTPMutationMethod(method string) bool {
switch strings.ToLower(strings.TrimSpace(method)) {
case "post", "put", "patch", "delete":
return true
default:
return false
}
}
func containsCommandToken(cmdLower, token string) bool {
for _, field := range strings.Fields(cmdLower) {
if commandTokenBase(field) == token {
return true
}
if strings.HasPrefix(field, "/") || strings.HasPrefix(field, "./") ||
strings.HasPrefix(field, "../") || strings.HasPrefix(field, "~/") {
if strings.HasSuffix(field, "/"+token) {
return true
}
}
}
return false
}
// hasShellChainingOutsideQuotes checks if a command contains shell chaining operators
// (;, &&, ||) outside of quoted strings. This allows SQL statements like "SELECT 1;"
// while still catching shell command chaining like "ls; rm -rf /".
//
// Handles escaped quotes (\' and \") by skipping the escaped character.
// Fails closed: if quote state becomes ambiguous (unclosed quotes), returns true.
func hasShellChainingOutsideQuotes(cmd string) bool {
inSingleQuote := false
inDoubleQuote := false
for i := 0; i < len(cmd); i++ {
ch := cmd[i]
// Handle escape sequences: skip the next character
// This prevents \" or \' from toggling quote state
if ch == '\\' && i+1 < len(cmd) {
i++ // Skip the escaped character
continue
}
// Track quote state
switch ch {
case '\'':
if !inDoubleQuote {
inSingleQuote = !inSingleQuote
}
case '"':
if !inSingleQuote {
inDoubleQuote = !inDoubleQuote
}
case ';':
if !inSingleQuote && !inDoubleQuote {
return true
}
case '&':
// Check for && (need to look at next char)
if !inSingleQuote && !inDoubleQuote && i+1 < len(cmd) && cmd[i+1] == '&' {
return true
}
case '|':
// Check for || (need to look at next char)
// Note: single | is a pipe, which is allowed for read operations
if !inSingleQuote && !inDoubleQuote && i+1 < len(cmd) && cmd[i+1] == '|' {
return true
}
}
}
// Fail closed: if quotes are unclosed, treat as potentially dangerous
// (ambiguous state means we can't be sure chaining operators are inside quotes)
if inSingleQuote || inDoubleQuote {
return true
}
return false
}
// splitChainedCommand splits a shell command on ;, &&, and || operators
// that appear outside of quoted strings. Returns the individual sub-commands
// with leading/trailing whitespace trimmed.
func splitChainedCommand(cmd string) []string {
var parts []string
inSingleQuote := false
inDoubleQuote := false
start := 0
for i := 0; i < len(cmd); i++ {
ch := cmd[i]
if ch == '\\' && i+1 < len(cmd) {
i++
continue
}
switch ch {
case '\'':
if !inDoubleQuote {
inSingleQuote = !inSingleQuote
}
case '"':
if !inSingleQuote {
inDoubleQuote = !inDoubleQuote
}
case ';':
if !inSingleQuote && !inDoubleQuote {
parts = append(parts, strings.TrimSpace(cmd[start:i]))
start = i + 1
}
case '&':
if !inSingleQuote && !inDoubleQuote && i+1 < len(cmd) && cmd[i+1] == '&' {
parts = append(parts, strings.TrimSpace(cmd[start:i]))
start = i + 2
i++ // skip second &
}
case '|':
if !inSingleQuote && !inDoubleQuote && i+1 < len(cmd) && cmd[i+1] == '|' {
parts = append(parts, strings.TrimSpace(cmd[start:i]))
start = i + 2
i++ // skip second |
}
}
}
// Add the last segment
if start < len(cmd) {
last := strings.TrimSpace(cmd[start:])
if last != "" {
parts = append(parts, last)
}
}
return parts
}
// classifyChainedCommand handles commands with shell chaining operators.
// It splits the command into sub-commands and classifies each individually.
// If ALL sub-commands are read-only, the chain is allowed.
// If ANY sub-command is write/unknown, the chain is blocked.
func classifyChainedCommand(command string) IntentResult {
parts := splitChainedCommand(command)
if len(parts) == 0 {
return IntentResult{Intent: IntentWriteOrUnknown, Reason: "empty chained command"}
}
// If splitting produced only 1 part (shouldn't happen since we checked
// hasShellChainingOutsideQuotes first), classify it directly.
if len(parts) == 1 {
return classifySingleCommand(parts[0])
}
for _, part := range parts {
if part == "" {
continue
}
result := classifySingleCommand(part)
if result.Intent != IntentReadOnlyCertain && result.Intent != IntentReadOnlyConditional {
return IntentResult{
Intent: IntentWriteOrUnknown,
Reason: fmt.Sprintf("chained sub-command not read-only: %s (reason: %s)", part, result.Reason),
}
}
}
return IntentResult{Intent: IntentReadOnlyCertain, Reason: "all chained sub-commands are read-only"}
}
// classifyCommandRisk provides backward-compatible risk classification.
// It delegates to ClassifyExecutionIntent and maps the result to CommandRisk,
// preserving the High/Medium write distinction for existing callers.
//
// Deprecated: Use ClassifyExecutionIntent for new code.
func classifyCommandRisk(command string) CommandRisk {
result := ClassifyExecutionIntent(command)
switch result.Intent {
case IntentReadOnlyCertain, IntentReadOnlyConditional:
return CommandRiskReadOnly
default:
// For backward compatibility, distinguish HighWrite from MediumWrite
// using the same pattern checks from matchesWritePatterns
return classifyWriteRiskLevel(command, result.Reason)
}
}
// classifyWriteRiskLevel determines whether a write command is high or medium risk.
// Used by classifyCommandRisk for backward compatibility.
func classifyWriteRiskLevel(command, reason string) CommandRisk {
cmdLower := strings.ToLower(command)
// High-risk: destructive system operations
highRiskPatterns := []string{
// Shell mutation capabilities (these dominate everything)
"> ", ">>", "| tee ",
// Destructive file operations
"rm ", "rm\t", "rmdir",
// System control
"shutdown", "reboot", "poweroff", "halt",
// Service control (except status)
"systemctl restart", "systemctl stop", "systemctl start",
"systemctl enable", "systemctl disable",
"service ", "init ",
// Package managers
"apt ", "apt-get ", "yum ", "dnf ", "pacman ", "apk ", "brew ",
"pip install", "pip uninstall", "npm install", "npm uninstall", "cargo install",
// Container destruction
"docker rm", "docker stop", "docker kill", "docker restart",
// Process termination
"kill ", "killall ", "pkill ",
// Disk operations
"dd ", "mkfs", "fdisk", "parted", "mkswap",
// Firewall
"iptables", "firewall-cmd", "ufw ",
// File truncation
"truncate",
// Permissions/ownership
"chmod ", "chown ", "chgrp ",
// User management
"useradd", "userdel", "usermod", "passwd", "chpasswd",
// Cron/sudoers
"crontab -", "visudo", "vipw",
// Mounts and kernel
"mount ", "umount ", "modprobe", "rmmod", "insmod", "sysctl -w",
// sudo escalation
"sudo",
}
for _, pattern := range highRiskPatterns {
if strings.Contains(cmdLower, pattern) {
return CommandRiskHighWrite
}
}
// Everything else is medium-risk
return CommandRiskMediumWrite
}
// GetReadOnlyViolationHint returns a hint for why a command was blocked from pulse_read.
// Uses the IntentResult reason plus context-aware suggestions.
func GetReadOnlyViolationHint(command string, result IntentResult) string {
baseHint := result.Reason
cmdLower := strings.ToLower(command)
// Phase 1 guardrail hints (structural issues that must be removed)
if isPhase1GuardrailFailure(result.Reason) {
return getPhase1Hint(result.Reason, baseHint)
}
// Content inspection hints (SQL CLIs, etc.)
isSQLCLI := strings.Contains(cmdLower, "sqlite3") ||
strings.Contains(cmdLower, "mysql") ||
strings.Contains(cmdLower, "mariadb") ||
strings.Contains(cmdLower, "psql")
if isSQLCLI {
return getSQLHint(result.Reason, baseHint)
}
// Unknown command fallback hint
if strings.Contains(result.Reason, "unknown") || strings.Contains(result.Reason, "no inspector") {
return baseHint + ". Try a self-contained form: no pipes, no redirects, single statement. If this is a read-only operation, consider using a known read-only command instead."
}
return baseHint
}
// isPhase1GuardrailFailure returns true if the reason indicates a Phase 1 structural issue.
func isPhase1GuardrailFailure(reason string) bool {
guardrailKeywords := []string{
"sudo", "redirect", "tee", "substitution", "chaining", "piped input",
// NonInteractiveOnly guardrails
"TTY", "terminal", "pager", "editor", "indefinitely", "unbounded", "streaming",
}
for _, kw := range guardrailKeywords {
if strings.Contains(reason, kw) {
return true
}
}
return false
}
// getPhase1Hint returns actionable hints for Phase 1 guardrail failures.
func getPhase1Hint(reason, baseHint string) string {
switch {
case strings.Contains(reason, "sudo"):
return baseHint + ". Read-only execution does not accept sudo; privileged operations require the governed control path."
case strings.Contains(reason, "redirect"):
return baseHint + ". Read-only execution does not accept redirects (>, >>, <, <<, <<<)."
case strings.Contains(reason, "tee"):
return baseHint + ". Read-only execution does not accept tee because tee writes to files."
case strings.Contains(reason, "substitution"):
return baseHint + ". Read-only execution does not accept $() or backticks."
case strings.Contains(reason, "chaining"):
return baseHint + ". Run commands separately instead of chaining with ; && ||."
case strings.Contains(reason, "piped input"):
return baseHint + ". For dual-use tools, include content directly instead of piping. Example: sqlite3 db.db \"SELECT ...\" instead of cat file | sqlite3 db.db"
// NonInteractiveOnly hints
case strings.Contains(reason, "TTY") || strings.Contains(reason, "terminal"):
return baseHint + ". Remove -it/--tty/--interactive flags. Use non-interactive form: docker exec container cmd (not docker exec -it)."
case strings.Contains(reason, "pager") || strings.Contains(reason, "editor"):
return baseHint + ". Use cat, head -n, or tail -n instead of interactive tools."
case strings.Contains(reason, "indefinitely"):
return baseHint + ". Use bounded alternatives: ps aux (not top), journalctl -n 100 (not watch)."
case strings.Contains(reason, "unbounded") || strings.Contains(reason, "streaming"):
return baseHint + ". Add line limit: journalctl -n 100 -f or tail -n 50 -f, or wrap with timeout."
default:
return baseHint + ". Read-only execution does not accept redirects, chaining, sudo, or subshells."
}
}
// getSQLHint returns actionable hints for SQL CLI content inspection failures.
func getSQLHint(reason, baseHint string) string {
switch {
case strings.Contains(reason, "external") || strings.Contains(reason, "no inline"):
return baseHint + ". Include SQL directly in quotes: sqlite3 db.db \"SELECT ...\""
case strings.Contains(reason, "write") || strings.Contains(reason, "control"):
return baseHint + ". Use only SELECT statements. Avoid: INSERT, UPDATE, DELETE, DROP, CREATE, PRAGMA, BEGIN, COMMIT, ROLLBACK."
default:
return baseHint + ". For read-only queries, use self-contained SELECT statements without transaction control."
}
}
const (
defaultMaxTopologyNodes = 5
defaultMaxTopologyVMsPerNode = 5
defaultMaxTopologyContainersPerNode = 5
defaultMaxTopologyDockerHosts = 3
defaultMaxTopologyDockerContainersPerHost = 5
defaultMaxTopologyK8sClusters = 3
defaultMaxTopologyK8sNodesPerCluster = 5
defaultMaxTopologyK8sDeploymentsPerCluster = 5
defaultMaxTopologyK8sPodsPerCluster = 10
defaultMaxListDockerContainersPerHost = 10
)
// registerResolvedResource adds a discovered resource to the resolved context if available.
// This is called by query tools when they find resources, enabling action tools to validate
// that commands are targeting legitimate, discovered resources.
//
// NOTE: This does NOT mark the resource as "recently accessed" for routing validation.
// Use registerResolvedResourceWithExplicitAccess() for single-resource operations where
// user intent is clear.
func (e *PulseToolExecutor) registerResolvedResource(reg ResourceRegistration) {
if e.resolvedContext == nil {
return
}
e.resolvedContext.AddResolvedResource(reg)
}
// registerResolvedResourceWithExplicitAccess adds a resource AND marks it as recently accessed.
// Use this for single-resource operations (pulse_query get, explicit select) where user
// intent to target this specific resource is clear.
//
// DO NOT use this for bulk operations (list, search) that return many resources,
// as it would poison routing validation and cause false ROUTING_MISMATCH blocks.
func (e *PulseToolExecutor) registerResolvedResourceWithExplicitAccess(reg ResourceRegistration) {
if e.resolvedContext == nil {
return
}
e.resolvedContext.AddResolvedResource(reg)
// Build the resource ID to mark explicit access (must match AddResolvedResource format)
// Format: {kind}:{host}:{provider_uid} for scoped resources
// {kind}:{provider_uid} for global resources
var resourceID string
if reg.ProviderUID != "" {
if reg.HostUID != "" || reg.HostName != "" {
hostScope := reg.HostUID
if hostScope == "" {
hostScope = reg.HostName
}
resourceID = reg.Kind + ":" + hostScope + ":" + reg.ProviderUID
} else {
resourceID = reg.Kind + ":" + reg.ProviderUID
}
} else {
if reg.HostUID != "" || reg.HostName != "" {
hostScope := reg.HostUID
if hostScope == "" {
hostScope = reg.HostName
}
resourceID = reg.Kind + ":" + hostScope + ":" + reg.Name
} else {
resourceID = reg.Kind + ":" + reg.Name
}
}
e.resolvedContext.MarkExplicitAccess(resourceID)
}
// ValidationResult holds the result of resource validation.
// Check StrictError first using errors.As() for typed error handling.
type ValidationResult struct {
Resource ResolvedResourceInfo
ErrorMsg string // Human-readable error (backwards compat)
StrictError *ErrStrictResolution // Typed error for strict mode violations
}
// IsBlocked returns true if the validation blocked the operation
func (v *ValidationResult) IsBlocked() bool {
return v.StrictError != nil
}
// validateResolvedResource checks if a resource has been previously discovered via query/discovery tools.
// Returns a ValidationResult containing:
// - Resource: the resolved resource info if found
// - ErrorMsg: human-readable error message (empty if valid)
// - StrictError: typed error for strict mode violations (nil if not blocked)
//
// Setting skipIfNoContext=true makes validation optional (for backwards compatibility).
//
// When PULSE_STRICT_RESOLUTION=true is set, write operations (start, stop, restart, delete, exec)
// will be blocked if the resource wasn't discovered first. This prevents the AI from operating
// on fabricated or hallucinated resource IDs.
func (e *PulseToolExecutor) validateResolvedResource(resourceName, action string, skipIfNoContext bool) ValidationResult {
// Determine if this requires hard validation (strict mode + write action)
strictMode := isStrictResolutionEnabled()
isWrite := isWriteAction(action)
requireHardValidation := strictMode && isWrite
if e.resolvedContext == nil {
if requireHardValidation {
// Record telemetry for strict resolution block
if e.telemetryCallback != nil {
e.telemetryCallback.RecordStrictResolutionBlock("validateResolvedResource", action)
}
err := &ErrStrictResolution{
ResourceID: resourceName,
Action: action,
Message: fmt.Sprintf("Resource '%s' has not been discovered. Resource discovery is required before performing '%s' action.", resourceName, action),
}
return ValidationResult{
ErrorMsg: err.Message,
StrictError: err,
}
}
if skipIfNoContext {
return ValidationResult{}
}
return ValidationResult{
ErrorMsg: fmt.Sprintf("Resource '%s' has not been discovered. Resource discovery is required first.", resourceName),
}
}
// First, try to find by alias (most common case - user refers to resources by name)
res, found := e.resolvedContext.GetResolvedResourceByAlias(resourceName)
if found {
// Check if action is allowed
allowedActions := res.GetAllowedActions()
if len(allowedActions) > 0 {
actionAllowed := false
for _, allowed := range allowedActions {
if allowed == action || allowed == "*" {
actionAllowed = true
break
}
}
if !actionAllowed {
return ValidationResult{
Resource: res,
ErrorMsg: fmt.Sprintf("Action '%s' is not permitted for resource '%s'. Allowed actions: %v", action, resourceName, allowedActions),
}
}
}
return ValidationResult{Resource: res}
}
// Try direct ID lookup (for when caller passes canonical ID)
res, found = e.resolvedContext.GetResolvedResourceByID(resourceName)
if found {
// Same action validation
allowedActions := res.GetAllowedActions()
if len(allowedActions) > 0 {
actionAllowed := false
for _, allowed := range allowedActions {
if allowed == action || allowed == "*" {
actionAllowed = true
break
}
}
if !actionAllowed {
return ValidationResult{
Resource: res,
ErrorMsg: fmt.Sprintf("Action '%s' is not permitted for resource '%s'. Allowed actions: %v", action, resourceName, allowedActions),
}
}
}
return ValidationResult{Resource: res}
}
// Resource not found
if requireHardValidation {
// Record telemetry for strict resolution block
if e.telemetryCallback != nil {
e.telemetryCallback.RecordStrictResolutionBlock("validateResolvedResource", action)
}
err := &ErrStrictResolution{
ResourceID: resourceName,
Action: action,
Message: fmt.Sprintf("Resource '%s' has not been discovered in this session. Resource discovery is required before performing '%s' action.", resourceName, action),
}
return ValidationResult{
ErrorMsg: err.Message,
StrictError: err,
}
}
// Allow operation if skipIfNoContext (backwards compat for soft validation)
if skipIfNoContext {
return ValidationResult{}
}
return ValidationResult{
ErrorMsg: fmt.Sprintf("Resource '%s' has not been discovered in this session. Resource discovery is required first.", resourceName),
}
}
// validateResolvedResourceForExec validates a resource for command execution.
// It uses command risk classification to determine if strict validation applies.
//
// Behavior in strict mode (PULSE_STRICT_RESOLUTION=true):
// - Read-only commands are allowed IF the session has ANY resolved context
// (prevents arbitrary host guessing while allowing diagnostic commands)
// - Write commands require the specific resource to be discovered first
//
// Behavior in normal mode:
// - All commands are allowed with soft validation (warning logs)
func (e *PulseToolExecutor) validateResolvedResourceForExec(resourceName, command string, skipIfNoContext bool) ValidationResult {
// Classify the command risk
risk := classifyCommandRisk(command)
// For read-only commands in strict mode, allow if session has ANY resolved context
// This prevents arbitrary host guessing while still allowing diagnostic commands
// on hosts that have been discovered (even if not the specific resource)
if risk == CommandRiskReadOnly && isStrictResolutionEnabled() {
// Check if there's any resolved context at all
if e.resolvedContext != nil {
// Try to find the resource - if found, great
result := e.validateResolvedResource(resourceName, "query", true)
if result.Resource != nil {
return result
}
// Resource not found, but we have some context - check if ANY host is discovered
// This is a scoped bypass: read-only commands allowed only if session is "active"
// (i.e., user has already done some discovery)
if e.hasAnyResolvedHost() {
// Allow read-only command with warning
return ValidationResult{
ErrorMsg: fmt.Sprintf("Resource '%s' not explicitly discovered, but allowing read-only command due to existing session context", resourceName),
}
}
}
// No context at all - require discovery even for read-only in strict mode
// Record telemetry for strict resolution block
if e.telemetryCallback != nil {
e.telemetryCallback.RecordStrictResolutionBlock("validateResolvedResourceForExec", "exec (read-only)")
}
return ValidationResult{
ErrorMsg: "No resources discovered in this session. Resource discovery is required first.",
StrictError: &ErrStrictResolution{
ResourceID: resourceName,
Action: "exec (read-only)",
Message: fmt.Sprintf("Resource '%s' cannot be accessed. No resources have been discovered in this session. Resource discovery is required first.", resourceName),
},
}
}
// For read-only commands in non-strict mode, use soft validation
if risk == CommandRiskReadOnly {
return e.validateResolvedResource(resourceName, "query", skipIfNoContext)
}
// For write commands, use "exec" action which triggers strict validation
return e.validateResolvedResource(resourceName, "exec", skipIfNoContext)
}
// hasAnyResolvedHost checks if there's at least one discovered resource in the session.
// This is used to scope read-only exec bypass - if the user has discovered ANY resource,
// we allow read-only commands to other resources (with warnings).
func (e *PulseToolExecutor) hasAnyResolvedHost() bool {
if e.resolvedContext == nil {
return false
}
return e.resolvedContext.HasAnyResources()
}
// RoutingValidationResult holds the result of routing context validation.
type RoutingValidationResult struct {
RoutingError *ErrRoutingMismatch // Non-nil if routing mismatch detected
}
// IsBlocked returns true if routing validation blocked the operation
func (r *RoutingValidationResult) IsBlocked() bool {
return r.RoutingError != nil
}
// validateRoutingContext checks if a target_host should be a more specific resource.
//
// This validation prevents the model from accidentally operating on a parent host
// when the user clearly intends to target a child resource (system container/VM) on that host.
//
// IMPORTANT: This check is intentionally scoped to RECENTLY ACCESSED resources to avoid
// false positives. The logic is:
//
// - If target_host resolves directly to a resource in ResolvedContext → OK
// - If target_host is a Proxmox node AND the user RECENTLY referenced child resources
// on that node (within RecentAccessWindow) → block with ROUTING_MISMATCH
//
// This prevents blocking legitimate host-level operations like "apt update on @pve-node"
// while still catching the "user said @homepage-docker but model targets pve-node" scenario.
//
// The key insight: if the user explicitly mentioned a child resource in this turn/exchange,
// they probably intend to target that child, not the parent host.
func (e *PulseToolExecutor) validateRoutingContext(targetHost string) RoutingValidationResult {
// Skip if no state provider or resolved context
if !e.hasReadState() || e.resolvedContext == nil {
return RoutingValidationResult{}
}
// First, check if targetHost resolves directly to a resource in ResolvedContext
// If so, no routing mismatch - user is targeting the right thing
if res, found := e.resolvedContext.GetResolvedResourceByAlias(targetHost); found {
// Target matches a resolved resource directly - no mismatch
_ = res
return RoutingValidationResult{}
}
// Check if targetHost is a Proxmox node (host)
loc := e.resolveResourceLocation(targetHost)
// Only check for mismatch if targetHost is a Proxmox node (host type)
if !loc.Found || loc.ResourceType != "node" {
return RoutingValidationResult{}
}
// targetHost is a Proxmox node. Check if ResolvedContext has RECENTLY ACCESSED
// child resources on this node (within the recent access window).
// This is the key refinement: we only block if the user recently referenced
// a child resource, implying they intended to target that child.
recentChildren := e.findRecentlyReferencedChildrenOnNode(loc.Node)
if len(recentChildren) == 0 {
return RoutingValidationResult{}
}
// Extract names, IDs, and kinds for the error response
var childNames []string
var childIDs []string
var childKinds []string
for _, child := range recentChildren {
childNames = append(childNames, child.Name)
childIDs = append(childIDs, child.ResourceID)
childKinds = append(childKinds, child.Kind)
}
// Record telemetry for routing mismatch block
// Use the first child kind for the label (we use small enums to avoid cardinality issues)
if e.telemetryCallback != nil && len(childKinds) > 0 {
e.telemetryCallback.RecordRoutingMismatchBlock("routing_validation", "node", childKinds[0])
}
// Rate-limited debug logging for support/debugging
// Logs: target_kind, child_kind, suggested_resource_id (no user paths to avoid cardinality)
logRoutingMismatchDebug(targetHost, childKinds, childIDs)
// Found recently referenced child resources! Block with ROUTING_MISMATCH
return RoutingValidationResult{
RoutingError: &ErrRoutingMismatch{
TargetHost: targetHost,
MoreSpecificResources: childNames,
MoreSpecificIDs: childIDs,
ChildKinds: childKinds,
Message: fmt.Sprintf(
"target_host '%s' is a hypervisor node, but you recently referenced more specific resources on it: %v. "+
"Did you mean to target one of these instead? File operations on a host node do NOT affect files inside guest VMs/containers.",
targetHost, childNames),
},
}
}
// recentChildInfo holds both the name and canonical ID of a recently referenced child resource.
type recentChildInfo struct {
Name string // Human-readable name
ResourceID string // Canonical ID (kind:scope:id)
Kind string // Resource kind (system-container, vm, app-container) for telemetry
}
// findRecentlyReferencedChildrenOnNode returns the names and IDs of guest resources (system containers/VMs) on a
// specific hypervisor node that were RECENTLY ACCESSED (within RecentAccessWindow).
//
// This is used by validateRoutingContext to detect when the user referenced a child resource
// in the current turn/exchange, indicating they probably intended to target that child.
func (e *PulseToolExecutor) findRecentlyReferencedChildrenOnNode(nodeName string) []recentChildInfo {
if e.resolvedContext == nil {
return nil
}
var children []recentChildInfo
rs, err := e.readStateForControl()
if err != nil {
return nil
}
// Check LXC containers on this node
for _, ct := range rs.Containers() {
if ct.Node() != nodeName {
continue
}
// Check if this LXC is in the resolved context AND was recently accessed
if res, found := e.resolvedContext.GetResolvedResourceByAlias(ct.Name()); found {
if res.GetKind() == "system-container" {
resourceID := res.GetResourceID()
if e.resolvedContext.WasRecentlyAccessed(resourceID, RecentAccessWindow) {
children = append(children, recentChildInfo{
Name: ct.Name(),
ResourceID: resourceID,
Kind: "system-container",
})
}
}
}
}
// Check VMs on this node
for _, vm := range rs.VMs() {
if vm.Node() != nodeName {
continue
}
// Check if this VM is in the resolved context AND was recently accessed
if res, found := e.resolvedContext.GetResolvedResourceByAlias(vm.Name()); found {
if res.GetKind() == "vm" {
resourceID := res.GetResourceID()
if e.resolvedContext.WasRecentlyAccessed(resourceID, RecentAccessWindow) {
children = append(children, recentChildInfo{
Name: vm.Name(),
ResourceID: resourceID,
Kind: "vm",
})
}
}
}
}
return children
}
// logRoutingMismatchDebug logs routing mismatch details for debugging and support.
// Rate-limited to avoid log spam (at most once per 10 seconds).
// Only logs safe, low-cardinality fields: target_kind, child_kind, suggested_resource_id.
func logRoutingMismatchDebug(targetHost string, childKinds, childIDs []string) {
routingMismatchLogLimiter.mu.Lock()
defer routingMismatchLogLimiter.mu.Unlock()
if time.Since(routingMismatchLogLimiter.lastLog) < routingMismatchLogLimiter.interval {
return // Rate limited
}
routingMismatchLogLimiter.lastLog = time.Now()
// Get first child kind and ID for logging (safe, low cardinality)
childKind := "unknown"
suggestedID := "none"
if len(childKinds) > 0 {
childKind = childKinds[0]
}
if len(childIDs) > 0 {
suggestedID = childIDs[0]
}
log.Debug().
Str("event", "routing_mismatch_block").
Str("target_kind", "node").
Str("child_kind", childKind).
Str("suggested_resource_id", suggestedID).
Int("affected_children", len(childIDs)).
Msg("[RoutingValidation] Blocked operation targeting parent node when child recently referenced")
}
// registerQueryTools registers the pulse_query tool
func (e *PulseToolExecutor) registerQueryTools() {
e.registry.registerBuiltin(RegisteredTool{
Definition: Tool{
Name: agentcapabilities.PulseQueryToolName,
Description: `Query and search canonical infrastructure resources. Start here to discover systems, workloads, storage, and disks by name. Actions: search, get, config, topology, list, health. Health returns the connection overview by default, or the canonical resource projection when resource_id is provided. command_agent_connected describes live command transport, independently of monitoring collection or freshness. Missing connection fields were not observed. can_execute describes connected transport with control enabled, not approval for a particular operation.`,
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
"action": {
Type: "string",
Description: "Query action to perform",
Enum: []string{"search", "get", "config", "topology", "list", "health"},
},
"query": {
Type: "string",
Description: "Search query (for action: search)",
},
"resource_type": {
Type: "string",
Description: "Resource type. For get/search, prefer canonical values: 'agent', 'vm', 'system-container', 'app-container', 'storage', 'physical-disk', and 'docker-host'. For get, 'node' resolves to 'agent'. For search, 'node' filters Proxmox nodes. Compatibility aliases 'system' and 'storage-pool' are still accepted. For config: 'vm', 'system-container', or TrueNAS 'app-container'. Docker and Podman app-container configuration reads are not supported. Their collected health, mounts, ports, and networks are available through get.",
Enum: []string{"agent", "system", "vm", "system-container", "app-container", "node", "docker-host", "storage", "storage-pool", "physical-disk"},
},
"resource_id": {
Type: "string",
Description: "Canonical resource identifier (for action: get, config, or resource-scoped health). Provider-native IDs and names are accepted as lookup aliases, but canonical unified resources are returned with their canonical ID.",
},
"type": {
Type: "string",
Description: "Filter by type. For action=list, use plural families such as 'agents', 'systems', 'nodes', 'vms', 'system-containers', 'app-containers', 'docker-hosts', 'storages', 'storage-pools', and 'physical-disks'. For action=search, singular canonical kinds such as 'agent' and 'storage' are also accepted.",
Enum: []string{"agent", "agents", "systems", "nodes", "vms", "system-containers", "app-containers", "docker-hosts", "storage", "storages", "storage-pools", "physical-disks", "kubernetes", "k8s-clusters", "k8s-nodes", "k8s-pods", "k8s-deployments"},
},
"status": {
Type: "string",
Description: "Filter by status (for action: search, list)",
},
"cluster_name": {
Type: "string",
Description: "Filter Kubernetes list responses by cluster name (for action: list)",
},
"namespace": {
Type: "string",
Description: "Filter Kubernetes pods/deployments by namespace (for action: list)",
},
"include": {
Type: "string",
Description: "Include filter for topology: 'all', 'proxmox', 'app-containers', 'kubernetes' (for action: topology)",
Enum: []string{"all", "proxmox", "app-containers", "kubernetes"},
},
"summary_only": {
Type: "boolean",
Description: "Return only summary counts for topology (for action: topology)",
},
"max_proxmox_nodes": {
Type: "integer",
Description: "Max Proxmox nodes to include (for action: topology)",
},
"max_vms_per_node": {
Type: "integer",
Description: "Max VMs per node (for action: topology)",
},
"max_containers_per_node": {
Type: "integer",
Description: "Max containers per node (for action: topology)",
},
"max_docker_hosts": {
Type: "integer",
Description: "Max Docker hosts (for action: topology)",
},
"max_docker_containers_per_host": {
Type: "integer",
Description: "Max Docker containers per host (for action: topology, list)",
},
"max_k8s_clusters": {
Type: "integer",
Description: "Max Kubernetes clusters to include (for action: topology)",
},
"max_k8s_nodes_per_cluster": {
Type: "integer",
Description: "Max Kubernetes nodes per cluster (for action: topology)",
},
"max_k8s_deployments_per_cluster": {
Type: "integer",
Description: "Max Kubernetes deployments per cluster (for action: topology)",
},
"max_k8s_pods_per_cluster": {
Type: "integer",
Description: "Max Kubernetes pods per cluster (for action: topology)",
},
"limit": {
Type: "integer",
Description: "Maximum number of results (default: 100). For topology, this is the fallback cap for every omitted max_* field; an explicit max_* field takes precedence.",
},
"offset": {
Type: "integer",
Description: "Number of results to skip",
},
},
Required: []string{"action"},
},
},
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeQuery(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: ToolApprovalScopeOnly,
ApprovalSummary: "no approval required",
Summary: "Resolves canonical infrastructure identity, topology, config, and health without changing state.",
},
})
}
func canonicalQueryResourceType(resourceType string) string {
switch strings.ToLower(strings.TrimSpace(resourceType)) {
case "system", "node":
return "agent"
case "storage-pool":
return "storage"
case "physical_disk":
return "physical-disk"
default:
return strings.ToLower(strings.TrimSpace(resourceType))
}
}
func canonicalQueryListType(filterType string) string {
switch strings.ToLower(strings.TrimSpace(filterType)) {
case "system", "agent", "agents":
return "systems"
case "node":
return "nodes"
case "vm":
return "vms"
case "system-container":
return "system-containers"
case "app-container":
return "app-containers"
case "docker-host":
return "docker-hosts"
case "storage", "storages", "storage-pool":
return "storage-pools"
case "physical-disk":
return "physical-disks"
case "k8s-cluster", "k8s-clusters":
return "k8s-clusters"
case "k8s-node", "k8s-nodes":
return "k8s-nodes"
case "k8s-pod", "k8s-pods":
return "k8s-pods"
case "k8s-deployment", "k8s-deployments":
return "k8s-deployments"
default:
return strings.ToLower(strings.TrimSpace(filterType))
}
}
func canonicalQueryTopologyInclude(include string) string {
switch strings.ToLower(strings.TrimSpace(include)) {
case "app-container":
return "app-containers"
default:
return strings.ToLower(strings.TrimSpace(include))
}
}
func canonicalQuerySearchType(typeFilter string) string {
switch strings.ToLower(strings.TrimSpace(typeFilter)) {
case "agent", "agents", "system", "systems":
return "agent"
case "system-containers":
return "system-container"
case "app-containers":
return "app-container"
case "docker-hosts":
return "docker-host"
case "storage", "storages", "storage-pool", "storage-pools":
return "storage"
case "physical-disks":
return "physical-disk"
case "docker-host":
return "docker-host"
default:
return strings.ToLower(strings.TrimSpace(typeFilter))
}
}
type governedQueryMetadataResolver struct {
rs unifiedresources.ReadState
cache map[string]GovernedResourceMetadata
}
func newGovernedQueryMetadataResolver(rs unifiedresources.ReadState) *governedQueryMetadataResolver {
return &governedQueryMetadataResolver{
rs: rs,
cache: make(map[string]GovernedResourceMetadata),
}
}
func (r *governedQueryMetadataResolver) Resolve(candidates ...string) GovernedResourceMetadata {
if r == nil || r.rs == nil {
return GovernedResourceMetadata{}
}
for _, candidate := range candidates {
key := strings.TrimSpace(candidate)
if key == "" {
continue
}
if metadata, ok := r.cache[key]; ok {
if metadata.Policy != nil || metadata.AISafeSummary != "" {
return metadata
}
continue
}
resolved := unifiedresources.ResolveResourceContext(r.rs, key)
metadata := governedQueryMetadataFromResolvedResource(resolved.Resource)
r.cache[key] = metadata
if metadata.Policy != nil || metadata.AISafeSummary != "" {
return metadata
}
}
return GovernedResourceMetadata{}
}
func governedQueryMetadataFromResolvedResource(resource *unifiedresources.Resource) GovernedResourceMetadata {
policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(resource)
return GovernedResourceMetadata{
Policy: policy,
AISafeSummary: aiSafeSummary,
}
}
func metricPercent(value *unifiedresources.MetricValue) float64 {
if value == nil {
return 0
}
return value.Percent
}
func metricUsedGB(value *unifiedresources.MetricValue) float64 {
if value == nil || value.Used == nil {
return 0
}
return float64(*value.Used) / (1024 * 1024 * 1024)
}
func metricTotalGB(value *unifiedresources.MetricValue) float64 {
if value == nil || value.Total == nil {
return 0
}
return float64(*value.Total) / (1024 * 1024 * 1024)
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func canonicalResourcePlatform(resource unifiedresources.Resource) string {
switch {
case resource.TrueNAS != nil || resourceHasTag(resource, "truenas"):
return "truenas"
case resource.VMware != nil:
return "vmware-vsphere"
case resource.Proxmox != nil:
return "proxmox"
case resource.Docker != nil:
return "docker"
case resource.Agent != nil && strings.TrimSpace(resource.Agent.Platform) != "":
return strings.ToLower(strings.TrimSpace(resource.Agent.Platform))
default:
return ""
}
}
func resourceHasTag(resource unifiedresources.Resource, needle string) bool {
for _, tag := range resource.Tags {
if strings.EqualFold(strings.TrimSpace(tag), needle) {
return true
}
}
return false
}
func resourceDisplayName(resource unifiedresources.Resource) string {
if name := unifiedresources.ResourceDisplayName(resource); name != "" {
return name
}
return strings.TrimSpace(resource.Name)
}
func resourceHostCandidates(resource unifiedresources.Resource) []string {
candidates := []string{resourceDisplayName(resource)}
if resource.Agent != nil {
candidates = append(candidates, strings.TrimSpace(resource.Agent.Hostname))
}
if resource.TrueNAS != nil {
candidates = append(candidates, strings.TrimSpace(resource.TrueNAS.Hostname))
}
if resource.Docker != nil {
candidates = append(candidates, strings.TrimSpace(resource.Docker.Hostname))
}
if resource.Proxmox != nil {
candidates = append(candidates, strings.TrimSpace(resource.Proxmox.NodeName))
}
candidates = append(candidates, resource.Identity.Hostnames...)
return candidates
}
// commandConnectionObservation keeps an unqueried snapshot distinct from an
// observed disconnected transport. It says nothing about telemetry freshness.
func commandConnectionObservation(snapshot map[string]bool, value bool) *bool {
if snapshot == nil {
return nil
}
return &value
}
func resourceCommandAgentConnected(resource unifiedresources.Resource, connected map[string]bool) *bool {
// A guest's provider node/host identifies placement, not a command
// connection inside the guest. Parent transport is projected separately.
candidates := []string{resourceDisplayName(resource)}
candidates = append(candidates, resource.Identity.Hostnames...)
if resource.Agent != nil {
candidates = append(candidates, resource.Agent.Hostname)
}
switch resource.Type {
case unifiedresources.ResourceTypeVM, unifiedresources.ResourceTypeSystemContainer, unifiedresources.ResourceTypeAppContainer:
// Only the guest's own identity can establish its direct connection.
default:
candidates = append(candidates, resourceHostCandidates(resource)...)
}
for _, candidate := range candidates {
if key := strings.TrimSpace(candidate); key != "" && connected[key] {
return commandConnectionObservation(connected, true)
}
}
return commandConnectionObservation(connected, false)
}
func appContainerProviderID(resource unifiedresources.Resource) string {
if resource.Docker != nil && strings.TrimSpace(resource.Docker.ContainerID) != "" {
return strings.TrimSpace(resource.Docker.ContainerID)
}
return strings.TrimSpace(resource.ID)
}
func canonicalAppContainerID(resource unifiedresources.Resource) string {
if resourceID := strings.TrimSpace(resource.ID); resourceID != "" {
return resourceID
}
return appContainerProviderID(resource)
}
func canonicalAppContainerState(resource unifiedresources.Resource) string {
if resource.Docker != nil && strings.TrimSpace(resource.Docker.ContainerState) != "" {
return strings.TrimSpace(resource.Docker.ContainerState)
}
return string(resource.Status)
}
func canonicalAppContainerHost(resource unifiedresources.Resource) string {
if host := strings.TrimSpace(resource.ParentName); host != "" {
return host
}
for _, candidate := range resource.Identity.Hostnames {
if strings.TrimSpace(candidate) != "" {
return strings.TrimSpace(candidate)
}
}
return ""
}
func matchCanonicalAppContainerResource(resource unifiedresources.Resource, resourceID string) (string, bool) {
query := strings.TrimSpace(resourceID)
if query == "" {
return "", false
}
canonicalID := canonicalAppContainerID(resource)
providerID := appContainerProviderID(resource)
if strings.EqualFold(canonicalID, query) ||
strings.EqualFold(providerID, query) ||
strings.EqualFold(resourceDisplayName(resource), query) ||
strings.HasPrefix(strings.ToLower(providerID), strings.ToLower(query)) {
return providerID, true
}
return "", false
}
func findCanonicalAppContainerResource(provider UnifiedResourceProvider, resourceID string) (unifiedresources.Resource, string, bool) {
if provider == nil {
return unifiedresources.Resource{}, "", false
}
for _, resource := range provider.GetByType(unifiedresources.ResourceTypeAppContainer) {
if containerID, ok := matchCanonicalAppContainerResource(resource, resourceID); ok {
return resource, containerID, true
}
}
return unifiedresources.Resource{}, "", false
}
func guestExecutorActions() []string {
return []string{"query", "get", "logs", "console", "exec", "start", "stop", "shutdown", "restart", "delete"}
}
func readOnlyResourceActions() []string {
return []string{"query", "get"}
}
func commandCapableAgentActions(resource unifiedresources.Resource) []string {
if resource.Agent != nil && resource.Agent.CommandsEnabled {
return []string{"query", "get", "exec"}
}
return readOnlyResourceActions()
}
func canonicalResolvedProviderUID(kind string, resource unifiedresources.Resource, fallbacks ...string) string {
id := strings.TrimSpace(resource.ID)
prefix := strings.TrimSpace(kind) + ":"
if strings.HasPrefix(id, prefix) {
return strings.TrimSpace(strings.TrimPrefix(id, prefix))
}
for _, fallback := range fallbacks {
if fallback = strings.TrimSpace(fallback); fallback != "" {
return fallback
}
}
return id
}
func canonicalAgentHost(resource unifiedresources.Resource) string {
if resource.Agent != nil && strings.TrimSpace(resource.Agent.Hostname) != "" {
return strings.TrimSpace(resource.Agent.Hostname)
}
if resource.TrueNAS != nil && strings.TrimSpace(resource.TrueNAS.Hostname) != "" {
return strings.TrimSpace(resource.TrueNAS.Hostname)
}
for _, hostname := range resource.Identity.Hostnames {
if hostname = strings.TrimSpace(hostname); hostname != "" {
return hostname
}
}
return strings.TrimSpace(resource.Name)
}
func canonicalAgentManagedID(resource unifiedresources.Resource) string {
if resource.VMware != nil && strings.EqualFold(strings.TrimSpace(resource.VMware.EntityType), "host") {
connectionID := strings.TrimSpace(resource.VMware.ConnectionID)
managedObjectID := strings.TrimSpace(resource.VMware.ManagedObjectID)
if connectionID != "" && managedObjectID != "" {
return connectionID + ":host:" + managedObjectID
}
return managedObjectID
}
return ""
}
func canonicalAgentSearchCandidates(resource unifiedresources.Resource) []string {
candidates := []string{
resourceDisplayName(resource),
strings.TrimSpace(resource.ID),
canonicalAgentManagedID(resource),
canonicalResourcePlatform(resource),
}
candidates = append(candidates, resourceHostCandidates(resource)...)
if resource.Agent != nil {
candidates = append(candidates, strings.TrimSpace(resource.Agent.AgentID), strings.TrimSpace(resource.Agent.MachineID))
}
if resource.VMware != nil {
candidates = append(candidates, strings.TrimSpace(resource.VMware.ConnectionName), strings.TrimSpace(resource.VMware.HostUUID))
}
return appendUniqueStrings(nil, candidates...)
}
func canonicalAgentRegistration(resource unifiedresources.Resource) (ResourceRegistration, bool) {
name := strings.TrimSpace(resourceDisplayName(resource))
providerUID := canonicalResolvedProviderUID("agent", resource, canonicalAgentManagedID(resource), canonicalAgentHost(resource))
if name == "" || providerUID == "" {
return ResourceRegistration{}, false
}
executorID := firstNonEmptyString(canonicalAgentHost(resource), name, providerUID)
adapter := canonicalResourcePlatform(resource)
if resource.Agent != nil && resource.Agent.CommandsEnabled {
adapter = "direct"
}
return ResourceRegistration{
Kind: "agent",
ProviderUID: providerUID,
Name: name,
Aliases: canonicalAgentSearchCandidates(resource),
Executors: []ExecutorRegistration{{
ExecutorID: executorID,
Adapter: adapter,
Actions: commandCapableAgentActions(resource),
Priority: 10,
}},
}, true
}
func canonicalStorageManagedID(resource unifiedresources.Resource) string {
if resource.VMware != nil && strings.EqualFold(strings.TrimSpace(resource.VMware.EntityType), "datastore") {
connectionID := strings.TrimSpace(resource.VMware.ConnectionID)
managedObjectID := strings.TrimSpace(resource.VMware.ManagedObjectID)
if connectionID != "" && managedObjectID != "" {
return connectionID + ":datastore:" + managedObjectID
}
return managedObjectID
}
return ""
}
func canonicalStorageHost(resource unifiedresources.Resource) string {
if parent := strings.TrimSpace(resource.ParentName); parent != "" {
return parent
}
if resource.Storage != nil && len(resource.Storage.Nodes) == 1 {
if node := strings.TrimSpace(resource.Storage.Nodes[0]); node != "" {
return node
}
}
return ""
}
func canonicalStorageSearchCandidates(resource unifiedresources.Resource) []string {
candidates := []string{
resourceDisplayName(resource),
strings.TrimSpace(resource.ID),
canonicalStorageManagedID(resource),
canonicalResourcePlatform(resource),
canonicalStorageHost(resource),
}
if resource.Storage != nil {
candidates = append(candidates, resource.Storage.Type, resource.Storage.Content, resource.Storage.Path)
candidates = append(candidates, resource.Storage.Nodes...)
}
if resource.VMware != nil {
candidates = append(candidates, strings.TrimSpace(resource.VMware.ConnectionName), strings.TrimSpace(resource.VMware.DatacenterName))
}
candidates = append(candidates, resource.Tags...)
return appendUniqueStrings(nil, candidates...)
}
func canonicalStorageRegistration(resource unifiedresources.Resource) (ResourceRegistration, bool) {
name := strings.TrimSpace(resourceDisplayName(resource))
providerUID := canonicalResolvedProviderUID("storage", resource, canonicalStorageManagedID(resource), name)
if name == "" || providerUID == "" {
return ResourceRegistration{}, false
}
executorID := firstNonEmptyString(canonicalStorageHost(resource), name, providerUID)
return ResourceRegistration{
Kind: "storage",
ProviderUID: providerUID,
Name: name,
Aliases: canonicalStorageSearchCandidates(resource),
Executors: []ExecutorRegistration{{
ExecutorID: executorID,
Adapter: canonicalResourcePlatform(resource),
Actions: readOnlyResourceActions(),
Priority: 10,
}},
}, true
}
// CanonicalHandoffUnifiedResource resolves a product-originated resource handoff
// into the canonical unified-resource record used by downstream governance.
func CanonicalHandoffUnifiedResource(provider UnifiedResourceProvider, resourceID, resourceName, resourceType, node string) (unifiedresources.Resource, bool) {
resource, _, ok := canonicalHandoffUnifiedResource(provider, resourceID, resourceName, resourceType, node)
return resource, ok
}
func canonicalHandoffUnifiedResource(provider UnifiedResourceProvider, resourceID, resourceName, resourceType, node string) (unifiedresources.Resource, string, bool) {
if provider == nil {
return unifiedresources.Resource{}, "", false
}
refs := appendUniqueStrings(nil, resourceID, resourceName)
normalizedType := strings.TrimSpace(strings.ToLower(resourceType))
switch normalizedType {
case "node":
normalizedType = "agent"
case "physical-disk":
normalizedType = "physical_disk"
}
switch normalizedType {
case "agent", "docker-host":
refs = appendUniqueStrings(refs, node)
if resource, ok := findCanonicalResourceByReference(provider.GetByType(unifiedresources.ResourceTypeAgent), refs...); ok {
return resource, normalizedType, true
}
case "vm":
if resource, ok := findCanonicalGuestResourceByReferences(provider, "vm", refs...); ok {
return resource, normalizedType, true
}
case "system-container", "lxc":
if resource, ok := findCanonicalGuestResourceByReferences(provider, "system-container", refs...); ok {
return resource, normalizedType, true
}
case "app-container":
if resource, ok := findCanonicalAppContainerResourceByReferences(provider, refs...); ok {
return resource, normalizedType, true
}
case "storage":
if resource, ok := findCanonicalResourceByReference(canonicalStoragePoolResources(provider), refs...); ok {
return resource, normalizedType, true
}
}
if resource, ok := findCanonicalHandoffResourceByAnyType(provider, refs...); ok {
return resource, string(unifiedresources.ContractResourceType(resource)), true
}
return unifiedresources.Resource{}, normalizedType, false
}
var canonicalHandoffFallbackResourceTypes = []unifiedresources.ResourceType{
unifiedresources.ResourceTypeAgent,
unifiedresources.ResourceTypeVM,
unifiedresources.ResourceTypeSystemContainer,
unifiedresources.ResourceTypeAppContainer,
unifiedresources.ResourceTypeDockerService,
unifiedresources.ResourceTypeDockerImage,
unifiedresources.ResourceTypeDockerVolume,
unifiedresources.ResourceTypeDockerNetwork,
unifiedresources.ResourceTypeDockerTask,
unifiedresources.ResourceTypeDockerSwarmNode,
unifiedresources.ResourceTypeDockerSecret,
unifiedresources.ResourceTypeDockerConfig,
unifiedresources.ResourceTypeK8sCluster,
unifiedresources.ResourceTypeK8sNode,
unifiedresources.ResourceTypePod,
unifiedresources.ResourceTypeK8sDeployment,
unifiedresources.ResourceTypeK8sReplicaSet,
unifiedresources.ResourceTypeK8sNamespace,
unifiedresources.ResourceTypeK8sService,
unifiedresources.ResourceTypeK8sStatefulSet,
unifiedresources.ResourceTypeK8sDaemonSet,
unifiedresources.ResourceTypeK8sJob,
unifiedresources.ResourceTypeK8sCronJob,
unifiedresources.ResourceTypeK8sIngress,
unifiedresources.ResourceTypeK8sEndpointSlice,
unifiedresources.ResourceTypeK8sNetworkPolicy,
unifiedresources.ResourceTypeK8sPV,
unifiedresources.ResourceTypeK8sPVC,
unifiedresources.ResourceTypeK8sStorageClass,
unifiedresources.ResourceTypeK8sConfigMap,
unifiedresources.ResourceTypeK8sSecret,
unifiedresources.ResourceTypeK8sServiceAccount,
unifiedresources.ResourceTypeK8sRole,
unifiedresources.ResourceTypeK8sClusterRole,
unifiedresources.ResourceTypeK8sRoleBinding,
unifiedresources.ResourceTypeK8sClusterRoleBinding,
unifiedresources.ResourceTypeK8sResourceQuota,
unifiedresources.ResourceTypeK8sLimitRange,
unifiedresources.ResourceTypeK8sPDB,
unifiedresources.ResourceTypeK8sHPA,
unifiedresources.ResourceTypeK8sEvent,
unifiedresources.ResourceTypeStorage,
unifiedresources.ResourceTypeNetwork,
unifiedresources.ResourceTypePBS,
unifiedresources.ResourceTypePMG,
unifiedresources.ResourceTypeCeph,
unifiedresources.ResourceTypePhysicalDisk,
unifiedresources.ResourceTypeNetworkShare,
unifiedresources.ResourceTypeNetworkEndpoint,
}
func findCanonicalHandoffResourceByAnyType(provider UnifiedResourceProvider, refs ...string) (unifiedresources.Resource, bool) {
for _, resourceType := range canonicalHandoffFallbackResourceTypes {
if resource, ok := findCanonicalResourceByReference(provider.GetByType(resourceType), refs...); ok {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
// CanonicalHandoffResourceRegistration resolves a product-originated resource
// handoff into the same governed registration shape used by explicit resource
// reads. It intentionally fails closed when the resource cannot be found in the
// unified provider, because invented registrations would bypass capability and
// executor policy.
func CanonicalHandoffResourceRegistration(provider UnifiedResourceProvider, resourceID, resourceName, resourceType, node string) (ResourceRegistration, bool) {
resource, normalizedType, ok := canonicalHandoffUnifiedResource(provider, resourceID, resourceName, resourceType, node)
if !ok {
return ResourceRegistration{}, false
}
switch normalizedType {
case "agent", "docker-host":
return canonicalAgentRegistration(resource)
case "vm":
return canonicalGuestRegistration("vm", resource)
case "system-container", "lxc":
return canonicalGuestRegistration("system-container", resource)
case "app-container":
return resolvedAppContainerRegistration(resource)
case "storage":
return canonicalStorageRegistration(resource)
default:
return ResourceRegistration{}, false
}
}
func findCanonicalResourceByReference(resources []unifiedresources.Resource, references ...string) (unifiedresources.Resource, bool) {
for _, ref := range references {
ref = strings.TrimSpace(ref)
if ref == "" {
continue
}
for _, resource := range resources {
if matchesCanonicalResourceReference(resource, ref, resourceDisplayName(resource), resource.Name) {
return resource, true
}
}
}
return unifiedresources.Resource{}, false
}
func findCanonicalGuestResourceByReferences(provider UnifiedResourceProvider, kind string, references ...string) (unifiedresources.Resource, bool) {
for _, ref := range references {
if resource, ok := findCanonicalGuestResource(provider, kind, ref); ok {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
func findCanonicalAppContainerResourceByReferences(provider UnifiedResourceProvider, references ...string) (unifiedresources.Resource, bool) {
for _, ref := range references {
if resource, _, ok := findCanonicalAppContainerResource(provider, ref); ok {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
func findCanonicalResourceByID(resources []unifiedresources.Resource, resourceID string) (unifiedresources.Resource, bool) {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
return unifiedresources.Resource{}, false
}
for _, resource := range resources {
if strings.EqualFold(strings.TrimSpace(resource.ID), resourceID) {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
func matchesCanonicalResourceReference(resource unifiedresources.Resource, resourceID string, additionalCandidates ...string) bool {
if matchesCanonicalResourceID(resource, resourceID) {
return true
}
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
return false
}
for _, candidate := range additionalCandidates {
if strings.EqualFold(strings.TrimSpace(candidate), resourceID) {
return true
}
}
return false
}
func canonicalGuestResourceType(kind string) (unifiedresources.ResourceType, bool) {
switch strings.TrimSpace(kind) {
case "vm":
return unifiedresources.ResourceTypeVM, true
case "system-container":
return unifiedresources.ResourceTypeSystemContainer, true
default:
return "", false
}
}
func canonicalGuestManagedID(resource unifiedresources.Resource) string {
if resource.Proxmox != nil && resource.Proxmox.VMID > 0 {
return strconv.Itoa(resource.Proxmox.VMID)
}
if resource.VMware != nil {
return strings.TrimSpace(resource.VMware.ManagedObjectID)
}
return ""
}
// queryGuestView is the read-state view method subset the guest get/search
// paths consume; *unifiedresources.VMView and *unifiedresources.ContainerView
// both satisfy it.
type queryGuestView interface {
ID() string
Name() string
VMID() int
Node() string
Instance() string
Status() unifiedresources.ResourceStatus
CPUPercent() float64
CPUs() int
MemoryPercent() float64
MemoryUsed() int64
MemoryTotal() int64
Tags() []string
IPAddresses() []string
LastBackup() time.Time
}
// addCanonicalGuestSearchMatches appends search matches for canonical guest
// resources of one kind ("vm" / "system-container"), shared by the
// resource-search guest arms.
func addCanonicalGuestSearchMatches(
kind string,
resources []unifiedresources.Resource,
queryLower, statusFilter string,
governance *governedQueryMetadataResolver,
connectedAgentHostnames map[string]bool,
matchesQuery func(query string, candidates ...string) bool,
addMatch func(ResourceMatch),
) {
for _, resource := range resources {
status := string(resource.Status)
if !statusMatchesFilter(status, statusFilter) {
continue
}
candidates := canonicalGuestSearchCandidates(kind, resource)
if !matchesQuery(queryLower, candidates...) {
continue
}
vmid := 0
if resource.Proxmox != nil && resource.Proxmox.VMID > 0 {
vmid = resource.Proxmox.VMID
}
node := canonicalGuestTarget(resource)
metadataCandidates := append([]string{resourceDisplayName(resource), resource.ID}, candidates...)
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(metadataCandidates...),
Type: kind,
ID: resource.ID,
Name: resourceDisplayName(resource),
Status: status,
Node: node,
NodeCommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[node]),
Platform: canonicalResourcePlatform(resource),
VMID: vmid,
CommandAgentConnected: resourceCommandAgentConnected(resource, connectedAgentHostnames),
})
}
}
// addGuestViewSearchMatches appends search matches for read-state guest views
// of one kind, the legacy fallback when no canonical guest resources exist.
func addGuestViewSearchMatches[V queryGuestView](
kind string,
guests []V,
queryLower, statusFilter string,
governance *governedQueryMetadataResolver,
connectedAgentHostnames map[string]bool,
matchesQuery func(query string, candidates ...string) bool,
addMatch func(ResourceMatch),
) {
for _, g := range guests {
status := string(g.Status())
if !statusMatchesFilter(status, statusFilter) {
continue
}
// Build searchable candidates: name, ID, VMID, canonical type-prefixed VMID, IPs, tags
vmidStr := fmt.Sprintf("%d", g.VMID())
candidates := []string{g.Name(), g.ID(), vmidStr, kind + vmidStr}
candidates = append(candidates, g.IPAddresses()...)
candidates = append(candidates, g.Tags()...)
if !matchesQuery(queryLower, candidates...) {
continue
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(g.Name(), g.ID(), vmidStr),
Type: kind,
ID: g.ID(),
Name: g.Name(),
Status: status,
Node: g.Node(),
NodeCommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[g.Node()]),
Platform: "proxmox",
VMID: g.VMID(),
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[g.Name()]),
})
}
}
// notFoundResourceResult is the shared not-found envelope for resource gets.
func notFoundResourceResult(kind, resourceID string) CallToolResult {
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceID,
"type": kind,
})
}
// canonicalGuestGetResult resolves a single-guest get against canonical
// unified resources, registering resolved-context access on a hit.
func (e *PulseToolExecutor) canonicalGuestGetResult(kind, resourceID string, governance *governedQueryMetadataResolver) (CallToolResult, bool) {
resource, ok := findCanonicalGuestResource(e.unifiedResourceProvider, kind, resourceID)
if !ok {
return CallToolResult{}, false
}
response := canonicalGuestResponse(kind, resource, governance)
if reg, ok := canonicalGuestRegistration(kind, resource); ok {
e.registerResolvedResourceWithExplicitAccess(reg)
}
return NewJSONResult(response), true
}
// guestViewGetResult resolves a single-guest get against read-state views
// (VMs or system containers), registering resolved-context access with the
// guest-exec adapter ("qm" / "pct") on a hit.
func guestViewGetResult[V queryGuestView](e *PulseToolExecutor, guests []V, kind, adapter, resourceID string, governance *governedQueryMetadataResolver) (CallToolResult, bool) {
for _, g := range guests {
if fmt.Sprintf("%d", g.VMID()) != resourceID && g.Name() != resourceID && g.ID() != resourceID {
continue
}
used := g.MemoryUsed()
total := g.MemoryTotal()
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(g.Name(), g.ID(), fmt.Sprintf("%d", g.VMID()))
response.Type = kind
response.ID = g.ID()
response.Name = g.Name()
response.Status = string(g.Status())
response.Node = g.Node()
response.CPU = ResourceCPU{
Percent: g.CPUPercent(),
Cores: g.CPUs(),
}
response.Memory = ResourceMemory{
Percent: g.MemoryPercent(),
UsedGB: float64(used) / (1024 * 1024 * 1024),
TotalGB: float64(total) / (1024 * 1024 * 1024),
}
response.Tags = g.Tags()
if !g.LastBackup().IsZero() {
t := g.LastBackup()
response.LastBackup = &t
}
// Register in resolved context WITH explicit access (single-resource get operation)
e.registerResolvedResourceWithExplicitAccess(ResourceRegistration{
Kind: kind,
ProviderUID: fmt.Sprintf("%d", g.VMID()), // VMID is the stable provider ID
Name: g.Name(),
Aliases: []string{g.Name(), fmt.Sprintf("%d", g.VMID()), g.ID()},
HostUID: g.Node(),
HostName: g.Node(),
VMID: g.VMID(),
Node: g.Node(),
LocationChain: []string{"node:" + g.Node(), kind + ":" + g.Name()},
Executors: []ExecutorRegistration{{
ExecutorID: g.Node(),
Adapter: adapter,
Actions: guestExecutorActions(),
Priority: 10,
}},
})
return NewJSONResult(response.NormalizeCollections()), true
}
return CallToolResult{}, false
}
func canonicalGuestTarget(resource unifiedresources.Resource) string {
if resource.Proxmox != nil {
return strings.TrimSpace(resource.Proxmox.NodeName)
}
if resource.VMware != nil {
return firstNonEmptyString(
resource.VMware.RuntimeHostName,
resource.VMware.RuntimeHostID,
resource.ParentName,
resource.VMware.ConnectionName,
)
}
return strings.TrimSpace(resource.ParentName)
}
func canonicalGuestSearchCandidates(kind string, resource unifiedresources.Resource) []string {
candidates := []string{
resourceDisplayName(resource),
strings.TrimSpace(resource.ID),
}
if managedID := canonicalGuestManagedID(resource); managedID != "" {
candidates = append(candidates, managedID)
}
if host := canonicalGuestTarget(resource); host != "" {
candidates = append(candidates, host)
}
candidates = append(candidates, resource.Identity.IPAddresses...)
candidates = append(candidates, resource.Tags...)
if resource.Proxmox != nil && resource.Proxmox.VMID > 0 {
if sourceID := strings.TrimSpace(resource.Proxmox.SourceID); sourceID != "" {
candidates = append(candidates, sourceID)
}
vmid := strconv.Itoa(resource.Proxmox.VMID)
switch strings.TrimSpace(kind) {
case "vm":
candidates = append(candidates, "vm"+vmid)
case "system-container":
candidates = append(candidates, "system-container"+vmid)
}
}
return candidates
}
func canonicalGuestMatchesResourceID(kind, resourceID string, resource unifiedresources.Resource) bool {
query := strings.TrimSpace(resourceID)
if query == "" {
return false
}
for _, candidate := range canonicalGuestSearchCandidates(kind, resource) {
if strings.EqualFold(strings.TrimSpace(candidate), query) {
return true
}
}
return false
}
func findCanonicalGuestResource(provider UnifiedResourceProvider, kind, resourceID string) (unifiedresources.Resource, bool) {
if provider == nil {
return unifiedresources.Resource{}, false
}
resourceType, ok := canonicalGuestResourceType(kind)
if !ok {
return unifiedresources.Resource{}, false
}
for _, resource := range provider.GetByType(resourceType) {
if canonicalGuestMatchesResourceID(kind, resourceID, resource) {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
func canonicalGuestRegistration(kind string, resource unifiedresources.Resource) (ResourceRegistration, bool) {
kind = strings.TrimSpace(kind)
name := strings.TrimSpace(resourceDisplayName(resource))
providerUID := firstNonEmptyString(canonicalGuestManagedID(resource), resource.ID)
if kind == "" || name == "" || providerUID == "" {
return ResourceRegistration{}, false
}
host := canonicalGuestTarget(resource)
aliases := appendUniqueStrings(nil, canonicalGuestSearchCandidates(kind, resource)...)
reg := ResourceRegistration{
Kind: kind,
ProviderUID: providerUID,
Name: name,
Aliases: aliases,
HostUID: host,
HostName: host,
Node: host,
}
switch kind {
case "vm":
reg.LocationChain = []string{"agent:" + firstNonEmptyString(host, name), "vm:" + name}
case "system-container":
reg.LocationChain = []string{"agent:" + firstNonEmptyString(host, name), "system-container:" + name}
}
switch canonicalResourcePlatform(resource) {
case "proxmox":
if resource.Proxmox == nil || host == "" {
return ResourceRegistration{}, false
}
reg.VMID = resource.Proxmox.VMID
reg.HostUID = strings.TrimSpace(resource.Proxmox.NodeName)
reg.HostName = strings.TrimSpace(resource.Proxmox.NodeName)
reg.Node = strings.TrimSpace(resource.Proxmox.NodeName)
adapter := "qm"
if kind == "system-container" {
adapter = "pct"
}
reg.Executors = []ExecutorRegistration{{
ExecutorID: reg.Node,
Adapter: adapter,
Actions: guestExecutorActions(),
Priority: 10,
}}
default:
executorID := firstNonEmptyString(host, name)
reg.Executors = []ExecutorRegistration{{
ExecutorID: executorID,
Adapter: canonicalResourcePlatform(resource),
Actions: readOnlyResourceActions(),
Priority: 10,
}}
}
return reg, true
}
func canonicalGuestResponse(kind string, resource unifiedresources.Resource, governance *governedQueryMetadataResolver) ResourceResponse {
candidates := append([]string{resourceDisplayName(resource), resource.ID}, canonicalGuestSearchCandidates(kind, resource)...)
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(candidates...)
response.Type = kind
response.ID = firstNonEmptyString(resource.ID, canonicalGuestManagedID(resource))
response.Name = resourceDisplayName(resource)
response.Status = string(resource.Status)
response.Platform = canonicalResourcePlatform(resource)
response.Node = canonicalGuestTarget(resource)
response.Host = canonicalGuestTarget(resource)
response.CPU = ResourceCPU{
Percent: metricPercent(resourceMetric(resource, "cpu")),
}
response.Memory = ResourceMemory{
Percent: metricPercent(resourceMetric(resource, "memory")),
UsedGB: metricUsedGB(resourceMetric(resource, "memory")),
TotalGB: metricTotalGB(resourceMetric(resource, "memory")),
}
if diskMetric := resourceMetric(resource, "disk"); diskMetric != nil {
response.Disk = &ResourceDisk{
Percent: metricPercent(diskMetric),
UsedGB: metricUsedGB(diskMetric),
TotalGB: metricTotalGB(diskMetric),
}
}
response.Tags = append([]string{}, resource.Tags...)
if resource.Proxmox != nil {
response.ID = firstNonEmptyString(resource.ID, resource.Proxmox.SourceID)
response.Node = strings.TrimSpace(resource.Proxmox.NodeName)
response.CPU.Cores = resource.Proxmox.CPUs
response.OS = strings.TrimSpace(resource.Proxmox.OSName)
if !resource.Proxmox.LastBackup.IsZero() {
t := resource.Proxmox.LastBackup
response.LastBackup = &t
}
}
if resource.VMware != nil {
response.ID = firstNonEmptyString(resource.ID, resource.VMware.ManagedObjectID)
response.Node = firstNonEmptyString(response.Node, resource.VMware.RuntimeHostName, resource.ParentName)
response.Host = firstNonEmptyString(response.Host, resource.VMware.RuntimeHostName, resource.ParentName)
if response.CPU.Cores == 0 {
response.CPU.Cores = resource.VMware.CPUCount
}
if response.Memory.TotalGB == 0 && resource.VMware.MemorySizeMiB > 0 {
response.Memory.TotalGB = float64(resource.VMware.MemorySizeMiB) / 1024
}
if response.OS == "" {
response.OS = strings.TrimSpace(resource.VMware.GuestOSFamily)
}
}
return response.NormalizeCollections()
}
func canonicalAgentResponse(resource unifiedresources.Resource, governance *governedQueryMetadataResolver) ResourceResponse {
candidates := canonicalAgentSearchCandidates(resource)
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(candidates...)
response.Type = "agent"
response.ID = strings.TrimSpace(resource.ID)
response.Name = resourceDisplayName(resource)
response.Status = string(resource.Status)
response.Platform = canonicalResourcePlatform(resource)
response.Host = canonicalAgentHost(resource)
response.CPU = ResourceCPU{
Percent: metricPercent(resourceMetric(resource, "cpu")),
}
response.Memory = ResourceMemory{
Percent: metricPercent(resourceMetric(resource, "memory")),
UsedGB: metricUsedGB(resourceMetric(resource, "memory")),
TotalGB: metricTotalGB(resourceMetric(resource, "memory")),
}
if diskMetric := resourceMetric(resource, "disk"); diskMetric != nil {
response.Disk = &ResourceDisk{
Percent: metricPercent(diskMetric),
UsedGB: metricUsedGB(diskMetric),
TotalGB: metricTotalGB(diskMetric),
}
}
response.Tags = append([]string{}, resource.Tags...)
if resource.Proxmox != nil {
response.CPU.Cores = unifiedresources.NewNodeView(&resource).CPUs()
}
if resource.Agent != nil {
response.OS = strings.TrimSpace(resource.Agent.OSName)
if resource.Agent.CPUCount > 0 {
response.CPU.Cores = resource.Agent.CPUCount
}
}
if response.Host == "" && resource.TrueNAS != nil {
response.Host = strings.TrimSpace(resource.TrueNAS.Hostname)
}
if response.Host == "" {
response.Host = canonicalAgentHost(resource)
}
return response.NormalizeCollections()
}
func canonicalStorageResponse(resource unifiedresources.Resource, governance *governedQueryMetadataResolver) ResourceResponse {
candidates := canonicalStorageSearchCandidates(resource)
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(candidates...)
response.Type = "storage"
response.ID = strings.TrimSpace(resource.ID)
response.Name = resourceDisplayName(resource)
response.Status = string(resource.Status)
response.Platform = canonicalResourcePlatform(resource)
response.Host = canonicalStorageHost(resource)
if response.Host != "" {
response.Node = response.Host
}
if diskMetric := resourceMetric(resource, "disk"); diskMetric != nil {
response.Disk = &ResourceDisk{
Percent: metricPercent(diskMetric),
UsedGB: metricUsedGB(diskMetric),
TotalGB: metricTotalGB(diskMetric),
}
}
response.Tags = append([]string{}, resource.Tags...)
return response.NormalizeCollections()
}
func canonicalAppContainerAdapter(resource unifiedresources.Resource) string {
switch canonicalResourcePlatform(resource) {
case "docker":
return "docker"
case "truenas":
return "truenas"
default:
return strings.TrimSpace(canonicalResourcePlatform(resource))
}
}
func canonicalCapabilityActions(resource unifiedresources.Resource) []string {
seen := make(map[string]struct{})
actions := make([]string, 0, len(resource.Capabilities))
for _, capability := range resource.Capabilities {
name := strings.ToLower(strings.TrimSpace(capability.Name))
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
actions = append(actions, name)
}
return actions
}
func appendUniqueStrings(base []string, values ...string) []string {
seen := make(map[string]struct{}, len(base))
for _, value := range base {
key := strings.TrimSpace(value)
if key == "" {
continue
}
seen[key] = struct{}{}
}
for _, value := range values {
key := strings.TrimSpace(value)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
base = append(base, key)
}
return base
}
func canonicalAppContainerActions(resource unifiedresources.Resource) []string {
actions := []string{"query", "get"}
switch canonicalResourcePlatform(resource) {
case "docker":
actions = append(actions, "logs", "exec", "restart", "stop", "start")
case "truenas":
actions = append(actions, "restart", "stop", "start")
}
return appendUniqueStrings(actions, canonicalCapabilityActions(resource)...)
}
func resolvedAppContainerRegistration(resource unifiedresources.Resource) (ResourceRegistration, bool) {
containerID := strings.TrimSpace(appContainerProviderID(resource))
name := strings.TrimSpace(resourceDisplayName(resource))
host := strings.TrimSpace(canonicalAppContainerHost(resource))
adapter := strings.TrimSpace(canonicalAppContainerAdapter(resource))
if containerID == "" || name == "" || host == "" || adapter == "" {
return ResourceRegistration{}, false
}
aliases := appendUniqueStrings(nil, name, containerID, strings.TrimSpace(resource.ID))
if adapter == "docker" && len(containerID) > 12 {
aliases = appendUniqueStrings(aliases, containerID[:12])
}
reg := ResourceRegistration{
Kind: "app-container",
ProviderUID: containerID,
Name: name,
Aliases: aliases,
HostUID: host,
HostName: host,
Executors: []ExecutorRegistration{{
ExecutorID: host,
Adapter: adapter,
Actions: canonicalAppContainerActions(resource),
Priority: 10,
}},
}
switch adapter {
case "docker":
reg.LocationChain = []string{"docker-host:" + host, "docker:" + name}
case "truenas":
reg.LocationChain = []string{"system:" + host, "app:" + name}
default:
reg.LocationChain = []string{"app-container:" + name}
}
return reg, true
}
func canonicalSystemSummaryFromResource(resource unifiedresources.Resource, connected map[string]bool) SystemSummary {
return SystemSummary{
ID: strings.TrimSpace(resource.ID),
Name: resourceDisplayName(resource),
Status: string(resource.Status),
Platform: canonicalResourcePlatform(resource),
ChildCount: resource.ChildCount,
CommandAgentConnected: resourceCommandAgentConnected(resource, connected),
CPU: metricPercent(resourceMetric(resource, "cpu")),
Memory: metricPercent(resourceMetric(resource, "memory")),
Disk: metricPercent(resourceMetric(resource, "disk")),
}
}
func canonicalAppContainerSummaryFromResource(resource unifiedresources.Resource) AppContainerSummary {
health := ""
image := ""
if resource.Docker != nil {
health = strings.TrimSpace(resource.Docker.Health)
image = strings.TrimSpace(resource.Docker.Image)
}
return AppContainerSummary{
ID: canonicalAppContainerID(resource),
Name: resourceDisplayName(resource),
Status: canonicalAppContainerState(resource),
Host: canonicalAppContainerHost(resource),
Platform: canonicalResourcePlatform(resource),
Image: image,
Health: health,
CPU: metricPercent(resourceMetric(resource, "cpu")),
Memory: metricPercent(resourceMetric(resource, "memory")),
Disk: metricPercent(resourceMetric(resource, "disk")),
}
}
func canonicalStoragePoolResources(provider UnifiedResourceProvider) []unifiedresources.Resource {
if provider == nil {
return []unifiedresources.Resource{}
}
storagePoolResources := make([]unifiedresources.Resource, 0)
for _, resource := range sortedResourcesByName(provider.GetByType(unifiedresources.ResourceTypeStorage)) {
if resource.Storage != nil && strings.EqualFold(strings.TrimSpace(resource.Storage.Topology), "dataset") {
continue
}
storagePoolResources = append(storagePoolResources, resource)
}
return storagePoolResources
}
func canonicalPhysicalDiskHost(resource unifiedresources.Resource) string {
for _, candidate := range resource.Identity.Hostnames {
if candidate = strings.TrimSpace(candidate); candidate != "" {
return candidate
}
}
return strings.TrimSpace(resource.ParentName)
}
func physicalDiskSummaryFromResource(resource unifiedresources.Resource) PhysicalDiskSummary {
pd := resource.PhysicalDisk
summary := PhysicalDiskSummary{
ID: resource.ID,
Node: canonicalPhysicalDiskHost(resource),
DevPath: "",
Model: "",
Serial: "",
WWN: "",
Type: "",
SizeBytes: 0,
Health: "",
Status: resource.Status,
SourceStatus: resource.SourceStatus,
Used: "",
LastChecked: resource.LastSeen,
}
if pd == nil {
return summary
}
summary.DevPath = pd.DevPath
summary.Model = pd.Model
summary.Serial = pd.Serial
summary.WWN = pd.WWN
summary.Type = pd.DiskType
summary.SizeBytes = pd.SizeBytes
summary.Health = pd.Health
summary.Risk = pd.Risk
summary.SMART = pd.SMART
summary.Used = pd.Used
if pd.Wearout >= 0 {
wearout := pd.Wearout
summary.LifeRemainingPercent = &wearout
}
if pd.Temperature > 0 {
temp := pd.Temperature
summary.Temperature = &temp
}
if pd.RPM > 0 {
rpm := pd.RPM
summary.RPM = &rpm
}
return summary
}
func resourceMetric(resource unifiedresources.Resource, kind string) *unifiedresources.MetricValue {
if resource.Metrics == nil {
return nil
}
switch kind {
case "cpu":
return resource.Metrics.CPU
case "memory":
return resource.Metrics.Memory
case "disk":
return resource.Metrics.Disk
default:
return nil
}
}
func sortedResourcesByName(resources []unifiedresources.Resource) []unifiedresources.Resource {
sorted := append([]unifiedresources.Resource(nil), resources...)
sort.Slice(sorted, func(i, j int) bool {
return strings.ToLower(resourceDisplayName(sorted[i])) < strings.ToLower(resourceDisplayName(sorted[j]))
})
return sorted
}
func matchesCanonicalResourceID(resource unifiedresources.Resource, resourceID string) bool {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
return false
}
if strings.EqualFold(strings.TrimSpace(resource.ID), resourceID) {
return true
}
if strings.EqualFold(resourceDisplayName(resource), resourceID) {
return true
}
return false
}
// executeQuery routes to the appropriate query handler based on action
func (e *PulseToolExecutor) executeQuery(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
action, _ := args["action"].(string)
switch action {
case "search":
return e.executeSearchResources(ctx, args)
case "get":
return e.executeGetResource(ctx, args)
case "config":
return e.executeGetResourceConfig(ctx, args)
case "topology":
return e.executeGetTopology(ctx, args)
case "list":
return e.executeListInfrastructure(ctx, args)
case "health":
return e.executeGetHealth(ctx, args)
default:
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: search, get, config, topology, list, health", action)), nil
}
}
// executeGetHealth preserves the fleet connection-health overview for callers
// that do not select a resource. Once a resource_id is present, health must be
// resolved through the same canonical, governed projection as action=get. This
// prevents a resource-scoped investigation from receiving unrelated aggregate
// connection counts while retaining backward compatibility for overview calls.
func (e *PulseToolExecutor) executeGetHealth(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
resourceID, _ := args["resource_id"].(string)
if strings.TrimSpace(resourceID) != "" {
return e.executeGetResource(ctx, args)
}
return e.executeGetConnectionHealth(ctx, args)
}
func (e *PulseToolExecutor) executeListInfrastructure(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
rs, err := e.readStateForControl()
if err != nil {
return NewErrorResult(err), nil
}
governance := newGovernedQueryMetadataResolver(rs)
filterType, _ := args["type"].(string)
filterType = canonicalQueryListType(filterType)
filterStatus, _ := args["status"].(string)
clusterNameFilter, _ := args["cluster_name"].(string)
namespaceFilter, _ := args["namespace"].(string)
limit := intArg(args, "limit", 100)
offset := intArg(args, "offset", 0)
maxDockerContainersPerHost := intArg(args, "max_docker_containers_per_host", 0)
if _, ok := args["max_docker_containers_per_host"]; !ok {
maxDockerContainersPerHost = defaultMaxListDockerContainersPerHost
}
if limit <= 0 {
limit = 100
}
if offset < 0 {
offset = 0
}
clusterNameFilter = strings.TrimSpace(clusterNameFilter)
namespaceFilter = strings.TrimSpace(namespaceFilter)
matchesCluster := func(clusterName string) bool {
if clusterNameFilter == "" {
return true
}
return strings.EqualFold(strings.TrimSpace(clusterName), clusterNameFilter)
}
matchesNamespace := func(namespace string) bool {
if namespaceFilter == "" {
return true
}
return strings.EqualFold(strings.TrimSpace(namespace), namespaceFilter)
}
normalizeClusterKey := func(clusterName string) string {
return strings.ToLower(strings.TrimSpace(clusterName))
}
// Build a set of connected agent hostnames for quick lookup
connectedAgentHostnames := make(map[string]bool)
if e.agentServer != nil {
for _, agent := range e.agentServer.GetConnectedAgents() {
connectedAgentHostnames[agent.Hostname] = true
}
}
systemResources := []unifiedresources.Resource{}
appContainerResources := []unifiedresources.Resource{}
storagePoolResources := []unifiedresources.Resource{}
physicalDiskResources := []unifiedresources.Resource{}
if e.unifiedResourceProvider != nil {
systemResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeAgent))
appContainerResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeAppContainer))
physicalDiskResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypePhysicalDisk))
storagePoolResources = canonicalStoragePoolResources(e.unifiedResourceProvider)
}
response := EmptyInfrastructureResponse()
response.Total = TotalCounts{
Systems: len(systemResources),
Nodes: len(rs.Nodes()),
VMs: len(rs.VMs()),
Containers: len(rs.Containers()),
AppContainers: len(appContainerResources),
DockerHosts: len(rs.DockerHosts()),
StoragePools: len(storagePoolResources),
PhysicalDisks: len(physicalDiskResources),
K8sClusters: len(rs.K8sClusters()),
K8sNodes: len(rs.K8sNodes()),
K8sPods: len(rs.Pods()),
K8sDeployments: len(rs.K8sDeployments()),
}
if response.Total.Systems == 0 {
response.Total.Systems = len(rs.Nodes()) + len(rs.Hosts()) + len(rs.DockerHosts())
}
if response.Total.AppContainers == 0 {
response.Total.AppContainers = len(rs.DockerContainers())
}
if response.Total.StoragePools == 0 {
response.Total.StoragePools = len(rs.StoragePools())
}
if response.Total.PhysicalDisks == 0 {
response.Total.PhysicalDisks = len(rs.PhysicalDisks())
}
totalMatches := 0
nodeCountByCluster := make(map[string]int)
deploymentCountByCluster := make(map[string]int)
podCountByCluster := make(map[string]int)
for _, node := range rs.K8sNodes() {
if node == nil {
continue
}
nodeCountByCluster[normalizeClusterKey(node.ClusterName())]++
}
for _, deployment := range rs.K8sDeployments() {
if deployment == nil {
continue
}
deploymentCountByCluster[normalizeClusterKey(deployment.ClusterName())]++
}
for _, pod := range rs.Pods() {
if pod == nil {
continue
}
podCountByCluster[normalizeClusterKey(pod.ClusterName())]++
}
dockerHostsByID := make(map[string]*unifiedresources.DockerHostView)
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
dockerHostsByID[host.ID()] = host
}
// Systems
if filterType == "" || filterType == "systems" {
count := 0
for _, resource := range systemResources {
if !statusMatchesFilter(string(resource.Status), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.Systems) >= limit {
count++
continue
}
summary := canonicalSystemSummaryFromResource(resource, connectedAgentHostnames)
summary.GovernedResourceMetadata = governance.Resolve(resourceDisplayName(resource), resource.ID)
response.Systems = append(response.Systems, summary)
count++
}
if filterType == "systems" {
totalMatches = count
}
}
// Nodes
if filterType == "" || filterType == "nodes" {
count := 0
for _, node := range rs.Nodes() {
if !statusMatchesFilter(string(node.Status()), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.Nodes) >= limit {
count++
continue
}
response.Nodes = append(response.Nodes, NodeSummary{
GovernedResourceMetadata: governance.Resolve(node.Name(), node.ID()),
Name: node.Name(),
Status: string(node.Status()),
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[node.Name()]),
})
count++
}
if filterType == "nodes" {
totalMatches = count
}
}
// VMs
if filterType == "" || filterType == "vms" {
count := 0
for _, vm := range rs.VMs() {
if !statusMatchesFilter(string(vm.Status()), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.VMs) >= limit {
count++
continue
}
response.VMs = append(response.VMs, VMSummary{
GovernedResourceMetadata: governance.Resolve(vm.Name(), vm.ID(), fmt.Sprintf("%d", vm.VMID())),
VMID: vm.VMID(),
Name: vm.Name(),
Status: string(vm.Status()),
Node: vm.Node(),
CPU: vm.CPUPercent(),
Memory: vm.MemoryPercent(),
})
count++
}
if filterType == "vms" {
totalMatches = count
}
}
// Containers (LXC)
if filterType == "" || filterType == "system-containers" {
count := 0
for _, ct := range rs.Containers() {
if !statusMatchesFilter(string(ct.Status()), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.Containers) >= limit {
count++
continue
}
response.Containers = append(response.Containers, ContainerSummary{
GovernedResourceMetadata: governance.Resolve(ct.Name(), ct.ID(), fmt.Sprintf("%d", ct.VMID())),
VMID: ct.VMID(),
Name: ct.Name(),
Status: string(ct.Status()),
Node: ct.Node(),
CPU: ct.CPUPercent(),
Memory: ct.MemoryPercent(),
})
count++
}
if filterType == "system-containers" {
totalMatches = count
}
}
// App containers
if filterType == "" || filterType == "app-containers" {
count := 0
for _, resource := range appContainerResources {
status := canonicalAppContainerState(resource)
if !statusMatchesFilter(status, filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.AppContainers) >= limit {
count++
continue
}
summary := canonicalAppContainerSummaryFromResource(resource)
summary.GovernedResourceMetadata = governance.Resolve(resourceDisplayName(resource), canonicalAppContainerID(resource), appContainerProviderID(resource))
response.AppContainers = append(response.AppContainers, summary)
count++
}
if len(appContainerResources) == 0 {
for _, container := range rs.DockerContainers() {
if container == nil {
continue
}
state := strings.TrimSpace(container.ContainerState())
if state == "" {
state = string(container.Status())
}
if !statusMatchesFilter(state, filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.AppContainers) >= limit {
count++
continue
}
hostName := ""
if host := dockerHostsByID[strings.TrimSpace(container.ParentID())]; host != nil {
hostName = strings.TrimSpace(host.Hostname())
if hostName == "" {
hostName = strings.TrimSpace(host.Name())
}
}
containerID := strings.TrimSpace(container.ContainerID())
if containerID == "" {
containerID = strings.TrimSpace(container.ID())
}
response.AppContainers = append(response.AppContainers, AppContainerSummary{
GovernedResourceMetadata: governance.Resolve(container.Name(), container.ID(), containerID),
ID: containerID,
Name: container.Name(),
Status: state,
Host: hostName,
Platform: "docker",
Image: container.Image(),
Health: container.Health(),
CPU: container.CPUPercent(),
Memory: container.MemoryPercent(),
})
count++
}
}
if filterType == "app-containers" {
totalMatches = count
}
}
// Docker hosts
if filterType == "" || filterType == "docker-hosts" {
containersByHost := make(map[string][]*unifiedresources.DockerContainerView)
for _, container := range rs.DockerContainers() {
if container == nil {
continue
}
parentID := strings.TrimSpace(container.ParentID())
if parentID == "" {
continue
}
containersByHost[parentID] = append(containersByHost[parentID], container)
}
count := 0
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
hostContainers := containersByHost[host.ID()]
if count < offset {
count++
continue
}
if len(response.DockerHosts) >= limit {
count++
continue
}
hostname := strings.TrimSpace(host.Hostname())
displayName := strings.TrimSpace(host.Name())
if hostname == "" {
hostname = displayName
}
dockerHost := DockerHostSummary{
GovernedResourceMetadata: governance.Resolve(host.Hostname(), host.Name(), host.HostSourceID(), host.ID()),
ID: host.ID(),
Hostname: hostname,
DisplayName: displayName,
ContainerCount: len(hostContainers),
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[hostname] || connectedAgentHostnames[displayName]),
}
for _, container := range hostContainers {
state := strings.TrimSpace(container.ContainerState())
if state == "" {
state = string(container.Status())
}
if !statusMatchesFilter(state, filterStatus) {
continue
}
if maxDockerContainersPerHost > 0 && len(dockerHost.Containers) >= maxDockerContainersPerHost {
continue
}
dockerHost.Containers = append(dockerHost.Containers, DockerContainerSummary{
GovernedResourceMetadata: governance.Resolve(container.Name(), container.ID(), container.ContainerID()),
ID: container.ID(),
Name: container.Name(),
State: state,
Image: container.Image(),
Health: container.Health(),
})
}
if filterStatus != "" && filterStatus != "all" && len(dockerHost.Containers) == 0 {
continue
}
response.DockerHosts = append(response.DockerHosts, dockerHost)
count++
}
if filterType == "docker-hosts" {
totalMatches = count
}
}
// Storage pools
if filterType == "" || filterType == "storage-pools" {
count := 0
for _, resource := range storagePoolResources {
if !statusMatchesFilter(string(resource.Status), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.StoragePools) >= limit {
count++
continue
}
response.StoragePools = append(response.StoragePools, storagePoolSummaryFromResource(resource))
count++
}
if filterType == "storage-pools" {
totalMatches = count
}
}
// Physical disks
if filterType == "" || filterType == "physical-disks" {
count := 0
for _, resource := range physicalDiskResources {
if !statusMatchesFilter(string(resource.Status), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.PhysicalDisks) >= limit {
count++
continue
}
response.PhysicalDisks = append(response.PhysicalDisks, physicalDiskSummaryFromResource(resource))
count++
}
if filterType == "physical-disks" {
totalMatches = count
}
}
// Kubernetes clusters
if filterType == "" || filterType == "kubernetes" || filterType == "k8s-clusters" {
count := 0
for _, cluster := range rs.K8sClusters() {
if cluster == nil {
continue
}
if !matchesCluster(cluster.Name()) {
continue
}
if !statusMatchesFilter(string(cluster.Status()), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.K8sClusters) >= limit {
count++
continue
}
key := normalizeClusterKey(cluster.Name())
response.K8sClusters = append(response.K8sClusters, K8sClusterSummary{
GovernedResourceMetadata: governance.Resolve(cluster.Name(), cluster.ID(), cluster.ClusterID()),
ID: cluster.ID(),
Name: cluster.Name(),
Status: string(cluster.Status()),
NodeCount: nodeCountByCluster[key],
DeploymentCount: deploymentCountByCluster[key],
PodCount: podCountByCluster[key],
})
count++
}
if filterType == "kubernetes" || filterType == "k8s-clusters" {
totalMatches = count
}
}
// Kubernetes nodes
if filterType == "" || filterType == "k8s-nodes" {
count := 0
for _, node := range rs.K8sNodes() {
if node == nil {
continue
}
if !matchesCluster(node.ClusterName()) {
continue
}
if !statusMatchesFilter(string(node.Status()), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.K8sNodes) >= limit {
count++
continue
}
response.K8sNodes = append(response.K8sNodes, K8sNodeSummary{
GovernedResourceMetadata: governance.Resolve(node.Name()),
Name: node.Name(),
Cluster: node.ClusterName(),
Status: string(node.Status()),
Ready: node.Ready(),
Roles: node.Roles(),
})
count++
}
if filterType == "k8s-nodes" {
totalMatches = count
}
}
// Kubernetes pods
if filterType == "" || filterType == "k8s-pods" {
count := 0
for _, pod := range rs.Pods() {
if pod == nil {
continue
}
if !matchesCluster(pod.ClusterName()) || !matchesNamespace(pod.Namespace()) {
continue
}
if !statusMatchesFilter(string(pod.Status()), filterStatus) && !statusMatchesFilter(pod.PodPhase(), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.K8sPods) >= limit {
count++
continue
}
response.K8sPods = append(response.K8sPods, K8sPodSummary{
GovernedResourceMetadata: governance.Resolve(pod.Name()),
Name: pod.Name(),
Cluster: pod.ClusterName(),
Namespace: pod.Namespace(),
Status: string(pod.Status()),
Restarts: pod.Restarts(),
OwnerKind: pod.OwnerKind(),
OwnerName: pod.OwnerName(),
})
count++
}
if filterType == "k8s-pods" {
totalMatches = count
}
}
// Kubernetes deployments
if filterType == "" || filterType == "k8s-deployments" {
count := 0
for _, deployment := range rs.K8sDeployments() {
if deployment == nil {
continue
}
if !matchesCluster(deployment.ClusterName()) || !matchesNamespace(deployment.Namespace()) {
continue
}
if !statusMatchesFilter(string(deployment.Status()), filterStatus) {
continue
}
if count < offset {
count++
continue
}
if len(response.K8sDeployments) >= limit {
count++
continue
}
response.K8sDeployments = append(response.K8sDeployments, K8sDeploymentSummary{
GovernedResourceMetadata: governance.Resolve(deployment.Name()),
Name: deployment.Name(),
Cluster: deployment.ClusterName(),
Namespace: deployment.Namespace(),
Status: string(deployment.Status()),
DesiredReplicas: deployment.DesiredReplicas(),
ReadyReplicas: deployment.ReadyReplicas(),
})
count++
}
if filterType == "k8s-deployments" {
totalMatches = count
}
}
if filterType != "" && (offset > 0 || totalMatches > limit) {
response.Pagination = &PaginationInfo{
Total: totalMatches,
Limit: limit,
Offset: offset,
}
}
return NewJSONResult(response.NormalizeCollections()), nil
}
type TopologyBuildOptions struct {
Include string
SummaryOnly bool
MaxProxmoxNodes int
MaxVMsPerNode int
MaxContainersPerNode int
MaxDockerHosts int
MaxDockerContainersPerHost int
MaxK8sClusters int
MaxK8sNodesPerCluster int
MaxK8sDeploymentsPerCluster int
MaxK8sPodsPerCluster int
ConnectedAgentHostnames map[string]bool // nil means command connections were not observed
ControlEnabled bool
}
func BuildTopologyResponseFromReadState(rs unifiedresources.ReadState, options TopologyBuildOptions) TopologyResponse {
response := EmptyTopologyResponse()
if rs == nil {
return response
}
include := canonicalQueryTopologyInclude(options.Include)
if include == "" {
include = "all"
}
includeProxmox := include == "all" || include == "proxmox"
includeDocker := include == "all" || include == "app-containers"
includeKubernetes := include == "all" || include == "kubernetes"
connectedAgentHostnames := options.ConnectedAgentHostnames
governance := newGovernedQueryMetadataResolver(rs)
summary := TopologySummary{
TotalNodes: len(rs.Nodes()),
TotalVMs: len(rs.VMs()),
TotalSystemContainers: len(rs.Containers()),
TotalDockerHosts: len(rs.DockerHosts()),
TotalDockerContainers: len(rs.DockerContainers()),
TotalK8sClusters: len(rs.K8sClusters()),
TotalK8sNodes: len(rs.K8sNodes()),
TotalK8sDeployments: len(rs.K8sDeployments()),
TotalK8sPods: len(rs.Pods()),
}
if connectedAgentHostnames != nil {
summary.NodesWithCommandAgents = new(int)
summary.DockerHostsWithCommandAgents = new(int)
}
for _, node := range rs.Nodes() {
if node == nil {
continue
}
if connectedAgentHostnames[node.Name()] {
(*summary.NodesWithCommandAgents)++
}
}
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
hostname := strings.TrimSpace(host.Hostname())
displayName := strings.TrimSpace(host.Name())
if connectedAgentHostnames[hostname] || connectedAgentHostnames[displayName] {
(*summary.DockerHostsWithCommandAgents)++
}
}
for _, pod := range rs.Pods() {
if pod == nil {
continue
}
if statusMatchesFilter(string(pod.Status()), "running") || statusMatchesFilter(pod.PodPhase(), "running") {
summary.RunningK8sPods++
}
}
nodeMap := make(map[string]*ProxmoxNodeTopology)
if includeProxmox && !options.SummaryOnly {
for _, node := range rs.Nodes() {
if node == nil {
continue
}
if options.MaxProxmoxNodes > 0 && len(nodeMap) >= options.MaxProxmoxNodes {
break
}
name := node.Name()
hasAgent := connectedAgentHostnames[name]
nodeMap[name] = &ProxmoxNodeTopology{
GovernedResourceMetadata: governance.Resolve(node.Name(), node.ID()),
Name: name,
Status: string(node.Status()),
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, hasAgent),
CanExecute: commandConnectionObservation(connectedAgentHostnames, hasAgent && options.ControlEnabled),
VMs: []TopologyVM{},
Containers: []TopologyContainer{},
}
}
}
ensureNode := func(name, status string) *ProxmoxNodeTopology {
if !includeProxmox || options.SummaryOnly {
return nil
}
if node, exists := nodeMap[name]; exists {
return node
}
if options.MaxProxmoxNodes > 0 && len(nodeMap) >= options.MaxProxmoxNodes {
return nil
}
hasAgent := connectedAgentHostnames[name]
nodeMap[name] = &ProxmoxNodeTopology{
GovernedResourceMetadata: governance.Resolve(name),
Name: name,
Status: status,
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, hasAgent),
CanExecute: commandConnectionObservation(connectedAgentHostnames, hasAgent && options.ControlEnabled),
VMs: []TopologyVM{},
Containers: []TopologyContainer{},
}
return nodeMap[name]
}
for _, vm := range rs.VMs() {
if vm == nil {
continue
}
status := string(vm.Status())
if status == "running" || status == string(unifiedresources.StatusOnline) {
summary.RunningVMs++
}
nodeTopology := ensureNode(vm.Node(), "unknown")
if nodeTopology == nil {
continue
}
nodeTopology.VMCount++
if options.MaxVMsPerNode <= 0 || len(nodeTopology.VMs) < options.MaxVMsPerNode {
nodeTopology.VMs = append(nodeTopology.VMs, TopologyVM{
GovernedResourceMetadata: governance.Resolve(vm.Name(), vm.ID(), fmt.Sprintf("%d", vm.VMID())),
VMID: vm.VMID(),
Name: vm.Name(),
Status: status,
CPU: vm.CPUPercent(),
Memory: vm.MemoryPercent(),
Tags: vm.Tags(),
})
}
}
for _, ct := range rs.Containers() {
if ct == nil {
continue
}
status := string(ct.Status())
if status == "running" || status == string(unifiedresources.StatusOnline) {
summary.RunningContainers++
}
nodeTopology := ensureNode(ct.Node(), "unknown")
if nodeTopology == nil {
continue
}
nodeTopology.ContainerCount++
if options.MaxContainersPerNode <= 0 || len(nodeTopology.Containers) < options.MaxContainersPerNode {
nodeTopology.Containers = append(nodeTopology.Containers, TopologyContainer{
GovernedResourceMetadata: governance.Resolve(ct.Name(), ct.ID(), fmt.Sprintf("%d", ct.VMID())),
VMID: ct.VMID(),
Name: ct.Name(),
Status: status,
CPU: ct.CPUPercent(),
Memory: ct.MemoryPercent(),
Tags: ct.Tags(),
})
}
}
proxmoxNodes := []ProxmoxNodeTopology{}
if includeProxmox && !options.SummaryOnly {
for _, node := range nodeMap {
proxmoxNodes = append(proxmoxNodes, *node)
}
sort.Slice(proxmoxNodes, func(i, j int) bool {
return strings.ToLower(proxmoxNodes[i].Name) < strings.ToLower(proxmoxNodes[j].Name)
})
for i := range proxmoxNodes {
sort.Slice(proxmoxNodes[i].VMs, func(a, b int) bool {
return strings.ToLower(proxmoxNodes[i].VMs[a].Name) < strings.ToLower(proxmoxNodes[i].VMs[b].Name)
})
sort.Slice(proxmoxNodes[i].Containers, func(a, b int) bool {
return strings.ToLower(proxmoxNodes[i].Containers[a].Name) < strings.ToLower(proxmoxNodes[i].Containers[b].Name)
})
}
}
containersByHost := make(map[string][]*unifiedresources.DockerContainerView)
for _, container := range rs.DockerContainers() {
if container == nil {
continue
}
parentID := strings.TrimSpace(container.ParentID())
if parentID == "" {
continue
}
containersByHost[parentID] = append(containersByHost[parentID], container)
if statusMatchesFilter(container.ContainerState(), "running") || statusMatchesFilter(string(container.Status()), "running") {
summary.RunningDocker++
}
}
dockerHosts := []DockerHostTopology{}
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
hostname := strings.TrimSpace(host.Hostname())
displayName := strings.TrimSpace(host.Name())
hasAgent := connectedAgentHostnames[hostname] || connectedAgentHostnames[displayName]
hostContainers := containersByHost[host.ID()]
runningCount := 0
var containers []DockerContainerSummary
for _, container := range hostContainers {
state := strings.TrimSpace(container.ContainerState())
if state == "" {
state = string(container.Status())
}
if statusMatchesFilter(state, "running") {
runningCount++
}
if includeDocker && !options.SummaryOnly {
if options.MaxDockerContainersPerHost <= 0 || len(containers) < options.MaxDockerContainersPerHost {
containers = append(containers, DockerContainerSummary{
GovernedResourceMetadata: governance.Resolve(container.Name(), container.ID(), container.ContainerID()),
ID: container.ID(),
Name: container.Name(),
State: state,
Image: container.Image(),
Health: container.Health(),
HealthcheckTargets: container.HealthcheckTargets(),
})
}
}
}
if includeDocker && !options.SummaryOnly {
if options.MaxDockerHosts > 0 && len(dockerHosts) >= options.MaxDockerHosts {
continue
}
if hostname == "" {
hostname = displayName
}
dockerHosts = append(dockerHosts, DockerHostTopology{
GovernedResourceMetadata: governance.Resolve(host.Hostname(), host.Name(), host.HostSourceID(), host.ID()),
Hostname: hostname,
DisplayName: displayName,
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, hasAgent),
CanExecute: commandConnectionObservation(connectedAgentHostnames, hasAgent && options.ControlEnabled),
Containers: containers,
ContainerCount: len(hostContainers),
ReturnedCount: len(containers),
Truncated: len(containers) < len(hostContainers),
RunningCount: runningCount,
})
}
}
sort.Slice(dockerHosts, func(i, j int) bool {
return strings.ToLower(dockerHosts[i].Hostname) < strings.ToLower(dockerHosts[j].Hostname)
})
k8sClusters := []KubernetesClusterTopology{}
clusterMap := make(map[string]*KubernetesClusterTopology)
clusterKeyByID := make(map[string]string)
clusterKeyByName := make(map[string]string)
ensureCluster := func(name, id, status string) *KubernetesClusterTopology {
if !includeKubernetes || options.SummaryOnly {
return nil
}
name = strings.TrimSpace(name)
id = strings.TrimSpace(id)
status = strings.TrimSpace(status)
if status == "" {
status = "unknown"
}
key := strings.ToLower(id)
if key == "" {
key = strings.ToLower(name)
}
if key == "" {
return nil
}
if existing, ok := clusterMap[key]; ok {
if existing.Name == "" && name != "" {
existing.Name = name
}
if existing.ID == "" && id != "" {
existing.ID = id
}
if existing.Status == "unknown" && status != "" {
existing.Status = status
}
return existing
}
if options.MaxK8sClusters > 0 && len(clusterMap) >= options.MaxK8sClusters {
return nil
}
cluster := &KubernetesClusterTopology{
GovernedResourceMetadata: governance.Resolve(name, id),
Name: name,
ID: id,
Status: status,
}
if cluster.Name == "" {
cluster.Name = cluster.ID
}
clusterMap[key] = cluster
if cluster.ID != "" {
clusterKeyByID[cluster.ID] = key
}
if cluster.Name != "" {
clusterKeyByName[strings.ToLower(cluster.Name)] = key
}
return cluster
}
resolveCluster := func(parentID, clusterName string) *KubernetesClusterTopology {
parentID = strings.TrimSpace(parentID)
clusterName = strings.TrimSpace(clusterName)
if parentID != "" {
if key, ok := clusterKeyByID[parentID]; ok {
return clusterMap[key]
}
}
if clusterName != "" {
if key, ok := clusterKeyByName[strings.ToLower(clusterName)]; ok {
return clusterMap[key]
}
}
return ensureCluster(clusterName, parentID, "unknown")
}
if includeKubernetes && !options.SummaryOnly {
for _, cluster := range rs.K8sClusters() {
if cluster == nil {
continue
}
ensureCluster(cluster.Name(), cluster.ID(), string(cluster.Status()))
}
}
for _, node := range rs.K8sNodes() {
if node == nil {
continue
}
cluster := resolveCluster(node.ParentID(), node.ClusterName())
if cluster == nil {
continue
}
cluster.NodeCount++
if includeKubernetes && !options.SummaryOnly && (options.MaxK8sNodesPerCluster <= 0 || len(cluster.Nodes) < options.MaxK8sNodesPerCluster) {
cluster.Nodes = append(cluster.Nodes, KubernetesNodeTopology{
GovernedResourceMetadata: governance.Resolve(node.Name()),
Name: node.Name(),
Status: string(node.Status()),
Ready: node.Ready(),
Roles: node.Roles(),
CPU: node.CPUPercent(),
Memory: node.MemoryPercent(),
})
}
}
for _, deployment := range rs.K8sDeployments() {
if deployment == nil {
continue
}
cluster := resolveCluster(deployment.ParentID(), deployment.ClusterName())
if cluster == nil {
continue
}
cluster.DeploymentCount++
if includeKubernetes && !options.SummaryOnly && (options.MaxK8sDeploymentsPerCluster <= 0 || len(cluster.Deployments) < options.MaxK8sDeploymentsPerCluster) {
cluster.Deployments = append(cluster.Deployments, KubernetesDeploymentDetail{
GovernedResourceMetadata: governance.Resolve(deployment.Name()),
Name: deployment.Name(),
Namespace: deployment.Namespace(),
Status: string(deployment.Status()),
DesiredReplicas: deployment.DesiredReplicas(),
ReadyReplicas: deployment.ReadyReplicas(),
})
}
}
for _, pod := range rs.Pods() {
if pod == nil {
continue
}
cluster := resolveCluster(pod.ParentID(), pod.ClusterName())
if cluster == nil {
continue
}
cluster.PodCount++
if includeKubernetes && !options.SummaryOnly && (options.MaxK8sPodsPerCluster <= 0 || len(cluster.Pods) < options.MaxK8sPodsPerCluster) {
cluster.Pods = append(cluster.Pods, KubernetesPodDetail{
GovernedResourceMetadata: governance.Resolve(pod.Name()),
Name: pod.Name(),
Namespace: pod.Namespace(),
Status: string(pod.Status()),
Restarts: pod.Restarts(),
OwnerKind: pod.OwnerKind(),
OwnerName: pod.OwnerName(),
})
}
}
if includeKubernetes && !options.SummaryOnly {
for _, cluster := range clusterMap {
k8sClusters = append(k8sClusters, *cluster)
}
sort.Slice(k8sClusters, func(i, j int) bool {
return strings.ToLower(k8sClusters[i].Name) < strings.ToLower(k8sClusters[j].Name)
})
for i := range k8sClusters {
sort.Slice(k8sClusters[i].Nodes, func(a, b int) bool {
return strings.ToLower(k8sClusters[i].Nodes[a].Name) < strings.ToLower(k8sClusters[i].Nodes[b].Name)
})
sort.Slice(k8sClusters[i].Deployments, func(a, b int) bool {
return strings.ToLower(k8sClusters[i].Deployments[a].Name) < strings.ToLower(k8sClusters[i].Deployments[b].Name)
})
sort.Slice(k8sClusters[i].Pods, func(a, b int) bool {
return strings.ToLower(k8sClusters[i].Pods[a].Name) < strings.ToLower(k8sClusters[i].Pods[b].Name)
})
}
}
response.Proxmox.Nodes = proxmoxNodes
response.Docker.Hosts = dockerHosts
response.Kubernetes.Clusters = k8sClusters
response.Summary = summary
return response.NormalizeCollections()
}
func (e *PulseToolExecutor) executeGetTopology(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
include, _ := args["include"].(string)
if include == "" {
include = "all"
}
include = canonicalQueryTopologyInclude(include)
switch include {
case "all", "proxmox", "app-containers", "kubernetes":
default:
return NewErrorResult(fmt.Errorf("invalid include: %s. Use all, proxmox, app-containers, or kubernetes", include)), nil
}
summaryOnly, summaryProvided := args["summary_only"].(bool)
maxProxmoxNodes := intArg(args, "max_proxmox_nodes", 0)
maxVMsPerNode := intArg(args, "max_vms_per_node", 0)
maxContainersPerNode := intArg(args, "max_containers_per_node", 0)
maxDockerHosts := intArg(args, "max_docker_hosts", 0)
maxDockerContainersPerHost := intArg(args, "max_docker_containers_per_host", 0)
maxK8sClusters := intArg(args, "max_k8s_clusters", 0)
maxK8sNodesPerCluster := intArg(args, "max_k8s_nodes_per_cluster", 0)
maxK8sDeploymentsPerCluster := intArg(args, "max_k8s_deployments_per_cluster", 0)
maxK8sPodsPerCluster := intArg(args, "max_k8s_pods_per_cluster", 0)
_, maxProxmoxNodesProvided := args["max_proxmox_nodes"]
_, maxVMsProvided := args["max_vms_per_node"]
_, maxContainersProvided := args["max_containers_per_node"]
_, maxDockerHostsProvided := args["max_docker_hosts"]
_, maxDockerContainersProvided := args["max_docker_containers_per_host"]
_, maxK8sClustersProvided := args["max_k8s_clusters"]
_, maxK8sNodesProvided := args["max_k8s_nodes_per_cluster"]
_, maxK8sDeploymentsProvided := args["max_k8s_deployments_per_cluster"]
_, maxK8sPodsProvided := args["max_k8s_pods_per_cluster"]
topologyLimit := intArg(args, "limit", 0)
_, topologyLimitProvided := args["limit"]
if topologyLimit <= 0 {
topologyLimitProvided = false
}
if !summaryProvided {
summaryOnly = false
}
if !summaryOnly {
// `limit` is the shared query bound the model naturally reaches for.
// Honor it for topology whenever a more precise max_* bound was not
// provided, rather than silently falling back to five nested resources.
applySharedLimit := func(value *int, provided *bool) {
if topologyLimitProvided && !*provided {
*value = topologyLimit
*provided = true
}
}
applySharedLimit(&maxProxmoxNodes, &maxProxmoxNodesProvided)
applySharedLimit(&maxVMsPerNode, &maxVMsProvided)
applySharedLimit(&maxContainersPerNode, &maxContainersProvided)
applySharedLimit(&maxDockerHosts, &maxDockerHostsProvided)
applySharedLimit(&maxDockerContainersPerHost, &maxDockerContainersProvided)
applySharedLimit(&maxK8sClusters, &maxK8sClustersProvided)
applySharedLimit(&maxK8sNodesPerCluster, &maxK8sNodesProvided)
applySharedLimit(&maxK8sDeploymentsPerCluster, &maxK8sDeploymentsProvided)
applySharedLimit(&maxK8sPodsPerCluster, &maxK8sPodsProvided)
if !maxProxmoxNodesProvided {
maxProxmoxNodes = defaultMaxTopologyNodes
}
if !maxVMsProvided {
maxVMsPerNode = defaultMaxTopologyVMsPerNode
}
if !maxContainersProvided {
maxContainersPerNode = defaultMaxTopologyContainersPerNode
}
if !maxDockerHostsProvided {
maxDockerHosts = defaultMaxTopologyDockerHosts
}
if !maxDockerContainersProvided {
maxDockerContainersPerHost = defaultMaxTopologyDockerContainersPerHost
}
if !maxK8sClustersProvided {
maxK8sClusters = defaultMaxTopologyK8sClusters
}
if !maxK8sNodesProvided {
maxK8sNodesPerCluster = defaultMaxTopologyK8sNodesPerCluster
}
if !maxK8sDeploymentsProvided {
maxK8sDeploymentsPerCluster = defaultMaxTopologyK8sDeploymentsPerCluster
}
if !maxK8sPodsProvided {
maxK8sPodsPerCluster = defaultMaxTopologyK8sPodsPerCluster
}
}
rs, err := e.readStateForControl()
if err != nil {
return NewErrorResult(err), nil
}
// Build a set of connected agent hostnames for quick lookup
connectedAgentHostnames := make(map[string]bool)
if e.agentServer != nil {
for _, agent := range e.agentServer.GetConnectedAgents() {
connectedAgentHostnames[agent.Hostname] = true
}
}
// Check if control is enabled
controlEnabled := e.controlLevel != ControlLevelReadOnly && e.controlLevel != ""
response := BuildTopologyResponseFromReadState(rs, TopologyBuildOptions{
Include: include,
SummaryOnly: summaryOnly,
MaxProxmoxNodes: maxProxmoxNodes,
MaxVMsPerNode: maxVMsPerNode,
MaxContainersPerNode: maxContainersPerNode,
MaxDockerHosts: maxDockerHosts,
MaxDockerContainersPerHost: maxDockerContainersPerHost,
MaxK8sClusters: maxK8sClusters,
MaxK8sNodesPerCluster: maxK8sNodesPerCluster,
MaxK8sDeploymentsPerCluster: maxK8sDeploymentsPerCluster,
MaxK8sPodsPerCluster: maxK8sPodsPerCluster,
ConnectedAgentHostnames: connectedAgentHostnames,
ControlEnabled: controlEnabled,
})
return NewJSONResult(response), nil
}
// resourceTypeFromCanonicalID extracts the resource type from a canonical
// resource id of the form "<type>-<hex hash>" (unifiedresources/ids.go builds
// ids as "<type>-<hex(sha256[:8])>"). The hash segment is unambiguous because no
// resource-type word is all-hex, so the type is everything before the trailing
// hex segment. Returns "" when the id is not a canonical handle (e.g. a bare
// numeric VMID), in which case the caller still requires an explicit type.
func resourceTypeFromCanonicalID(id string) string {
id = strings.TrimSpace(id)
idx := strings.LastIndex(id, "-")
if idx <= 0 || idx >= len(id)-1 {
return ""
}
suffix := id[idx+1:]
if len(suffix) < 7 || len(suffix) > 64 {
return ""
}
for _, r := range suffix {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
return ""
}
}
return id[:idx]
}
func (e *PulseToolExecutor) executeGetResource(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
resourceType, _ := args["resource_type"].(string)
resourceID, _ := args["resource_id"].(string)
resourceTypeRaw := strings.TrimSpace(resourceType)
resourceType = canonicalQueryResourceType(resourceType)
if IsCurrentResourceReference(resourceID) || IsCurrentResourceReference(resourceTypeRaw) {
resource, err := e.resolveCurrentResource()
if err != nil {
return NewErrorResult(err), nil
}
resourceType = canonicalQueryTypeForResolvedResource(resource)
resourceTypeRaw = resourceType
resourceID = canonicalQueryIDForResolvedResource(resource)
}
if resourceType == "" {
// Models often pass a single canonical handle ("system-container-<hash>")
// as resource_id without a separate resource_type, because the handle
// already encodes the type (unifiedresources/ids.go: "<type>-<hash>").
// Infer the type from it instead of failing the call — otherwise the model
// burns one or more visibly-failed "resource_type is required" tool calls
// before recovering with the numeric id.
if inferred := resourceTypeFromCanonicalID(resourceID); inferred != "" {
resourceType = canonicalQueryResourceType(inferred)
resourceTypeRaw = inferred
}
}
if resourceType == "" {
return NewErrorResult(fmt.Errorf("resource_type is required")), nil
}
if resourceID == "" {
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
}
if resourceType == "physical-disk" {
if e.unifiedResourceProvider == nil {
return NewErrorResult(fmt.Errorf("physical disk inventory is unavailable")), nil
}
for _, resource := range e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypePhysicalDisk) {
if resource.ID == strings.TrimSpace(resourceID) {
return NewJSONResult(physicalDiskSummaryFromResource(resource)), nil
}
}
return NewErrorResult(fmt.Errorf("physical disk %q not found in canonical inventory", resourceID)), nil
}
rs, err := e.readStateForControl()
if err != nil {
return NewTextResult("State information not available."), nil
}
governance := newGovernedQueryMetadataResolver(rs)
switch resourceType {
case "agent":
if e.unifiedResourceProvider != nil {
for _, resource := range e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeAgent) {
if !matchesCanonicalResourceReference(resource, resourceID, canonicalAgentSearchCandidates(resource)...) {
continue
}
response := canonicalAgentResponse(resource, governance)
if reg, ok := canonicalAgentRegistration(resource); ok {
e.registerResolvedResourceWithExplicitAccess(reg)
}
return NewJSONResult(response), nil
}
}
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceID,
"type": "agent",
}), nil
case "docker-host":
for _, host := range rs.DockerHosts() {
if host == nil || !matchesCanonicalResourceReference(
unifiedresources.Resource{}, resourceID, host.ID(), host.HostSourceID(), host.Hostname(), host.Name(), host.DisplayName(),
) {
continue
}
hostID := firstNonEmptyString(host.HostSourceID(), host.ID())
hostName := firstNonEmptyString(host.Hostname(), host.Name(), host.DisplayName())
displayName := firstNonEmptyString(host.Name(), host.DisplayName(), hostName)
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(host.Hostname(), host.Name(), host.DisplayName(), host.HostSourceID(), host.ID())
response.Type = "docker-host"
response.ID = hostID
response.Name = displayName
response.Status = string(host.Status())
response.Platform = "docker"
response.Host = hostName
response.OS = strings.TrimSpace(host.OS())
response.CPU = ResourceCPU{Percent: host.CPUPercent(), Cores: host.CPUs()}
response.Memory = ResourceMemory{
Percent: host.MemoryPercent(),
UsedGB: float64(host.MemoryUsed()) / (1024 * 1024 * 1024),
TotalGB: float64(host.MemoryTotal()) / (1024 * 1024 * 1024),
}
response.Tags = host.Tags()
e.registerResolvedResourceWithExplicitAccess(ResourceRegistration{
Kind: "docker-host",
ProviderUID: hostID,
Name: displayName,
Aliases: appendUniqueStrings(nil, displayName, hostID, hostName, host.ID()),
HostUID: hostID,
HostName: hostName,
LocationChain: []string{"docker-host:" + hostName},
Executors: []ExecutorRegistration{{
ExecutorID: hostName,
Adapter: "direct",
Actions: readOnlyResourceActions(),
Priority: 10,
}},
})
return NewJSONResult(response.NormalizeCollections()), nil
}
return notFoundResourceResult("docker-host", resourceID), nil
case "storage":
if e.unifiedResourceProvider != nil {
for _, resource := range canonicalStoragePoolResources(e.unifiedResourceProvider) {
if !matchesCanonicalResourceReference(resource, resourceID, canonicalStorageSearchCandidates(resource)...) {
continue
}
response := canonicalStorageResponse(resource, governance)
if reg, ok := canonicalStorageRegistration(resource); ok {
e.registerResolvedResourceWithExplicitAccess(reg)
}
return NewJSONResult(response), nil
}
}
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceID,
"type": "storage",
}), nil
case "vm":
if result, ok := e.canonicalGuestGetResult("vm", resourceID, governance); ok {
return result, nil
}
if result, ok := guestViewGetResult(e, rs.VMs(), "vm", "qm", resourceID, governance); ok {
return result, nil
}
return notFoundResourceResult("vm", resourceID), nil
case "system-container":
if result, ok := e.canonicalGuestGetResult("system-container", resourceID, governance); ok {
return result, nil
}
if result, ok := guestViewGetResult(e, rs.Containers(), "system-container", "pct", resourceID, governance); ok {
return result, nil
}
return notFoundResourceResult("system-container", resourceID), nil
case "app-container":
if resource, containerID, ok := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceID); ok {
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(resourceDisplayName(resource), canonicalAppContainerID(resource), containerID)
response.Type = "app-container"
response.ID = canonicalAppContainerID(resource)
response.Name = resourceDisplayName(resource)
response.Status = canonicalAppContainerState(resource)
response.Platform = canonicalResourcePlatform(resource)
response.Host = canonicalAppContainerHost(resource)
response.CPU = ResourceCPU{
Percent: metricPercent(resourceMetric(resource, "cpu")),
}
response.Memory = ResourceMemory{
Percent: metricPercent(resourceMetric(resource, "memory")),
UsedGB: metricUsedGB(resourceMetric(resource, "memory")),
TotalGB: metricTotalGB(resourceMetric(resource, "memory")),
}
if diskMetric := resourceMetric(resource, "disk"); diskMetric != nil {
response.Disk = &ResourceDisk{
Percent: metricPercent(diskMetric),
UsedGB: metricUsedGB(diskMetric),
TotalGB: metricTotalGB(diskMetric),
}
}
response.Tags = append([]string{}, resource.Tags...)
if resource.Docker != nil {
response.Image = strings.TrimSpace(resource.Docker.Image)
response.Health = strings.TrimSpace(resource.Docker.Health)
response.HealthcheckTargets = append([]string{}, resource.Docker.HealthcheckTargets...)
response.RestartCount = resource.Docker.RestartCount
response.Labels = resource.Docker.Labels
if update := resource.Docker.UpdateStatus; update != nil && update.UpdateAvailable {
response.UpdateAvailable = true
}
for _, p := range resource.Docker.Ports {
response.Ports = append(response.Ports, PortInfo{
Private: p.PrivatePort,
Public: p.PublicPort,
Protocol: p.Protocol,
IP: p.IP,
})
}
for _, n := range resource.Docker.Networks {
addresses := make([]string, 0, 2)
if n.IPv4 != "" {
addresses = append(addresses, n.IPv4)
}
if n.IPv6 != "" {
addresses = append(addresses, n.IPv6)
}
response.Networks = append(response.Networks, NetworkInfo{
Name: n.Name,
Addresses: addresses,
})
}
for _, m := range resource.Docker.Mounts {
response.Mounts = append(response.Mounts, MountInfo{
Type: m.Type,
Source: m.Source,
Destination: m.Destination,
Mode: m.Mode,
ReadWrite: m.RW,
})
}
}
if reg, ok := resolvedAppContainerRegistration(resource); ok {
e.registerResolvedResourceWithExplicitAccess(reg)
}
return NewJSONResult(response.NormalizeCollections()), nil
}
dockerHostsByID := make(map[string]*unifiedresources.DockerHostView)
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
dockerHostsByID[host.ID()] = host
}
for _, container := range rs.DockerContainers() {
if container == nil {
continue
}
containerID := strings.TrimSpace(container.ContainerID())
if containerID == "" {
containerID = strings.TrimSpace(container.ID())
}
if containerID != resourceID && container.Name() != resourceID && !strings.HasPrefix(containerID, resourceID) {
continue
}
state := strings.TrimSpace(container.ContainerState())
if state == "" {
state = string(container.Status())
}
hostName := ""
hostUID := ""
executorID := ""
if host := dockerHostsByID[strings.TrimSpace(container.ParentID())]; host != nil {
hostName = strings.TrimSpace(host.Hostname())
hostUID = strings.TrimSpace(host.HostSourceID())
if hostUID == "" {
hostUID = strings.TrimSpace(host.ID())
}
if hostName == "" {
hostName = strings.TrimSpace(host.Name())
}
executorID = hostName
if executorID == "" {
executorID = hostUID
}
}
response := EmptyResourceResponse()
response.GovernedResourceMetadata = governance.Resolve(container.Name(), container.ID(), containerID)
response.Type = "app-container"
response.ID = containerID
response.Name = container.Name()
response.Status = state
response.Host = hostName
response.Image = container.Image()
response.Health = container.Health()
response.HealthcheckTargets = container.HealthcheckTargets()
response.CPU = ResourceCPU{
Percent: container.CPUPercent(),
}
response.Memory = ResourceMemory{
Percent: container.MemoryPercent(),
UsedGB: float64(container.MemoryUsed()) / (1024 * 1024 * 1024),
TotalGB: float64(container.MemoryTotal()) / (1024 * 1024 * 1024),
}
response.RestartCount = container.RestartCount()
response.Labels = container.Labels()
if update := container.UpdateStatus(); update != nil && update.UpdateAvailable {
response.UpdateAvailable = true
}
for _, p := range container.Ports() {
response.Ports = append(response.Ports, PortInfo{
Private: p.PrivatePort,
Public: p.PublicPort,
Protocol: p.Protocol,
IP: p.IP,
})
}
for _, n := range container.Networks() {
addresses := make([]string, 0, 2)
if n.IPv4 != "" {
addresses = append(addresses, n.IPv4)
}
if n.IPv6 != "" {
addresses = append(addresses, n.IPv6)
}
response.Networks = append(response.Networks, NetworkInfo{
Name: n.Name,
Addresses: addresses,
})
}
for _, m := range container.Mounts() {
response.Mounts = append(response.Mounts, MountInfo{
Type: m.Type,
Source: m.Source,
Destination: m.Destination,
Mode: m.Mode,
ReadWrite: m.RW,
})
}
// Register in resolved context WITH explicit access (single-resource get operation)
aliases := []string{container.Name(), containerID}
if len(containerID) > 12 {
aliases = append(aliases, containerID[:12]) // Add short ID for longer IDs
}
locationHost := hostName
if locationHost == "" {
locationHost = hostUID
}
if locationHost == "" {
locationHost = "unknown"
}
if hostName == "" {
hostName = locationHost
}
if hostUID == "" {
hostUID = locationHost
}
if executorID == "" {
executorID = locationHost
}
response.Host = hostName
e.registerResolvedResourceWithExplicitAccess(ResourceRegistration{
Kind: "app-container",
ProviderUID: containerID, // Docker container ID is the stable provider ID
Name: container.Name(),
Aliases: aliases,
HostUID: hostUID,
HostName: hostName,
LocationChain: []string{"docker-host:" + locationHost, "docker:" + container.Name()},
Executors: []ExecutorRegistration{{
ExecutorID: executorID,
Adapter: "docker",
Actions: []string{"query", "get", "logs", "exec", "restart", "stop", "start"},
Priority: 10,
}},
})
return NewJSONResult(response.NormalizeCollections()), nil
}
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceID,
"type": "app-container",
}), nil
default:
return NewErrorResult(fmt.Errorf("invalid resource_type: %s. Use 'agent', 'vm', 'system-container', 'app-container', 'docker-host', or 'storage' (compatibility aliases 'system' and 'storage-pool' are also accepted)", resourceTypeRaw)), nil
}
}
func (e *PulseToolExecutor) executeGetResourceConfig(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
resourceType, _ := args["resource_type"].(string)
resourceID, _ := args["resource_id"].(string)
resourceType = canonicalQueryResourceType(resourceType)
if resourceType == "" {
return NewErrorResult(fmt.Errorf("resource_type is required")), nil
}
if resourceID == "" {
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
}
if resourceType == "app-container" {
return e.executeNativeAppContainerConfig(ctx, strings.TrimSpace(resourceID))
}
if e.guestConfigProvider == nil {
return NewTextResult("Guest configuration not available."), nil
}
var (
guestType string
vmID int
name string
node string
instance string
)
var err error
rs, err := e.readStateForControl()
if err != nil {
return NewTextResult("State information not available."), nil
}
governance := newGovernedQueryMetadataResolver(rs)
guestType, vmID, name, node, instance, err = resolveGuestFromReadState(rs, resourceType, resourceID)
if err != nil {
return NewErrorResult(err), nil
}
// Normalize semantic type to provider-level type for guest config lookup.
// GetGuestConfig expects "container" or "vm", not "system-container".
configType := guestType
if configType == "system-container" {
configType = "container"
}
rawConfig, err := e.guestConfigProvider.GetGuestConfig(configType, instance, node, vmID)
if err != nil {
return NewErrorResult(err), nil
}
response := EmptyGuestConfigResponse()
response.GovernedResourceMetadata = governance.Resolve(name, fmt.Sprintf("%d", vmID))
response.GuestType = guestType
response.VMID = vmID
response.Name = name
response.Node = node
response.Instance = instance
switch guestType {
case "system-container":
hostname, osType, onboot, rootfs, mounts := parseContainerConfig(rawConfig)
response.Hostname = hostname
response.OSType = osType
response.Onboot = onboot
response.RootFS = rootfs
response.Mounts = mounts
case "vm":
osType, onboot, disks := parseVMConfig(rawConfig)
response.OSType = osType
response.Onboot = onboot
response.Disks = disks
default:
return NewErrorResult(fmt.Errorf("unsupported guest type: %s", guestType)), nil
}
return NewJSONResult(response.NormalizeCollections()), nil
}
func (e *PulseToolExecutor) executeNativeAppContainerConfig(ctx context.Context, resourceRef string) (CallToolResult, error) {
validation := e.validateResolvedResource(resourceRef, "query", true)
if validation.ErrorMsg != "" {
return NewErrorResult(fmt.Errorf("%s", validation.ErrorMsg)), nil
}
if validation.Resource != nil && validation.Resource.GetKind() != "app-container" {
return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceRef, validation.Resource.GetKind())), nil
}
if e.unifiedResourceProvider == nil {
return NewErrorResult(fmt.Errorf("current app-container inventory is unavailable")), nil
}
rs, err := e.readStateForControl()
if err != nil {
return NewErrorResult(fmt.Errorf("current app-container state is unavailable: %w", err)), nil
}
governance := newGovernedQueryMetadataResolver(rs)
resource, providerID, found := findCanonicalAppContainerResource(e.unifiedResourceProvider, resourceRef)
if !found {
return NewJSONResult(map[string]interface{}{
"error": "not_found",
"resource_id": resourceRef,
"type": "app-container",
}), nil
}
// Inventory owns read identity and capability. Optional session discovery
// supplies restrictions and continuity, not proof that the resource exists.
resourceID := canonicalAppContainerID(resource)
canonicalValidation := e.validateResolvedResource(resourceID, "query", true)
if canonicalValidation.ErrorMsg != "" {
return NewErrorResult(fmt.Errorf("%s", canonicalValidation.ErrorMsg)), nil
}
if canonicalValidation.Resource != nil && canonicalValidation.Resource.GetKind() != "app-container" {
return NewErrorResult(fmt.Errorf("resource '%s' is %q, not app-container", resourceID, canonicalValidation.Resource.GetKind())), nil
}
platform := canonicalAppContainerAdapter(resource)
unavailable := func(reason, message string) CallToolResult {
return NewJSONResultWithIsError(map[string]interface{}{
"available": false, "reason": reason, "message": message,
"resource_id": resourceID, "type": "app-container", "platform": platform,
}, true)
}
if platform != "truenas" {
return unavailable("unsupported_adapter", "The resource exists, but its adapter does not support configuration reads."), nil
}
if e.appContainerConfigProvider == nil {
return unavailable("provider_unavailable", "The resource exists, but its configuration provider is unavailable."), nil
}
reg, ok := resolvedAppContainerRegistration(resource)
if !ok {
return unavailable("resource_context_unavailable", "The resource exists, but its current provider identity or placement is incomplete."), nil
}
// Do not overwrite an existing session's allowed actions during a read.
if validation.Resource == nil && canonicalValidation.Resource == nil {
e.registerResolvedResourceWithExplicitAccess(reg)
}
result, err := e.appContainerConfigProvider.GetConfig(ctx, AppContainerConfigRequest{
OrgID: e.orgID,
ResourceID: resourceID,
ProviderUID: providerID,
Name: resourceDisplayName(resource),
Host: canonicalAppContainerHost(resource),
Platform: platform,
})
if err != nil {
return NewErrorResult(err), nil
}
if result == nil {
return unavailable("empty_provider_response", "The resource exists, but the provider returned no configuration observation."), nil
}
response := EmptyAppContainerConfigResponse()
response.GovernedResourceMetadata = governance.Resolve(result.Name, result.ResourceID, result.ProviderUID)
response.Type = "app-container"
response.ID = result.ProviderUID
if response.ID == "" {
response.ID = strings.TrimSpace(result.ResourceID)
}
response.Name = result.Name
response.Host = result.Host
response.Platform = result.Platform
response.Status = result.Status
response.Version = result.Version
response.HumanVersion = result.HumanVersion
response.Notes = result.Notes
response.CustomApp = result.CustomApp
response.UpgradeAvailable = result.UpgradeAvailable
response.ImageUpdatesAvailable = result.ImageUpdatesAvailable
response.ContainerCount = result.ContainerCount
response.UsedHostIPs = append([]string{}, result.UsedHostIPs...)
response.Images = append([]string{}, result.Images...)
response.Ports = append([]PortInfo{}, result.Ports...)
response.Networks = append([]NetworkInfo{}, result.Networks...)
response.Mounts = append([]MountInfo{}, result.Mounts...)
response.Containers = append([]AppContainerConfigContainer{}, result.Containers...)
if response.ID == "" {
response.ID = providerID
}
if response.Name == "" {
response.Name = resourceDisplayName(resource)
}
if response.Host == "" {
response.Host = canonicalAppContainerHost(resource)
}
if response.Platform == "" {
response.Platform = platform
}
if response.GovernedResourceMetadata.Policy == nil && response.AISafeSummary == "" {
response.GovernedResourceMetadata = governance.Resolve(response.Name, resourceID, response.ID)
}
return NewJSONResult(response.NormalizeCollections()), nil
}
func resolveGuestFromReadState(rs unifiedresources.ReadState, resourceType, resourceID string) (guestType string, vmID int, name, node, instance string, err error) {
resourceType = canonicalQueryResourceType(resourceType)
resourceID = strings.TrimSpace(resourceID)
if resourceType == "" || resourceID == "" {
return "", 0, "", "", "", fmt.Errorf("resource_type and resource_id are required")
}
switch resourceType {
case "system-container":
for _, ct := range rs.Containers() {
if fmt.Sprintf("%d", ct.VMID()) == resourceID || ct.Name() == resourceID || ct.ID() == resourceID {
return "system-container", ct.VMID(), ct.Name(), ct.Node(), ct.Instance(), nil
}
}
return "", 0, "", "", "", fmt.Errorf("system-container not found: %s", resourceID)
case "vm":
for _, vm := range rs.VMs() {
if fmt.Sprintf("%d", vm.VMID()) == resourceID || vm.Name() == resourceID || vm.ID() == resourceID {
return "vm", vm.VMID(), vm.Name(), vm.Node(), vm.Instance(), nil
}
}
return "", 0, "", "", "", fmt.Errorf("vm not found: %s", resourceID)
default:
return "", 0, "", "", "", fmt.Errorf("invalid resource_type: %s. Use vm or system-container", resourceType)
}
}
func parseContainerConfig(config map[string]interface{}) (hostname, osType string, onboot *bool, rootfs string, mounts []GuestMountConfig) {
if len(config) == 0 {
return "", "", nil, "", nil
}
for key, value := range config {
lowerKey := strings.ToLower(strings.TrimSpace(key))
switch lowerKey {
case "hostname":
hostname = strings.TrimSpace(fmt.Sprint(value))
case "ostype":
osType = strings.TrimSpace(fmt.Sprint(value))
case "onboot":
onboot = parseOnbootValue(value)
}
if lowerKey != "rootfs" && !strings.HasPrefix(lowerKey, "mp") {
continue
}
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" {
continue
}
source, mountpoint := parseMountValue(raw)
if lowerKey == "rootfs" {
rootfs = source
if mountpoint == "" {
mountpoint = "/"
}
}
mounts = append(mounts, GuestMountConfig{
Key: lowerKey,
Source: source,
Mountpoint: mountpoint,
})
}
if len(mounts) > 1 {
sort.Slice(mounts, func(i, j int) bool {
return mounts[i].Key < mounts[j].Key
})
}
return hostname, osType, onboot, rootfs, mounts
}
func parseVMConfig(config map[string]interface{}) (osType string, onboot *bool, disks []GuestDiskConfig) {
if len(config) == 0 {
return "", nil, nil
}
for key, value := range config {
lowerKey := strings.ToLower(strings.TrimSpace(key))
switch lowerKey {
case "ostype":
osType = strings.TrimSpace(fmt.Sprint(value))
case "onboot":
onboot = parseOnbootValue(value)
}
if !isVMConfigDiskKey(lowerKey) {
continue
}
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" {
continue
}
disks = append(disks, GuestDiskConfig{
Key: lowerKey,
Value: raw,
})
}
if len(disks) > 1 {
sort.Slice(disks, func(i, j int) bool {
return disks[i].Key < disks[j].Key
})
}
return osType, onboot, disks
}
func isVMConfigDiskKey(key string) bool {
if strings.HasPrefix(key, "scsi") ||
strings.HasPrefix(key, "virtio") ||
strings.HasPrefix(key, "sata") ||
strings.HasPrefix(key, "ide") ||
strings.HasPrefix(key, "unused") ||
strings.HasPrefix(key, "efidisk") ||
strings.HasPrefix(key, "tpmstate") {
return true
}
return false
}
func parseMountValue(raw string) (source, mountpoint string) {
parts := strings.Split(raw, ",")
if len(parts) > 0 {
source = strings.TrimSpace(parts[0])
}
for _, part := range parts[1:] {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
k := strings.ToLower(strings.TrimSpace(kv[0]))
v := strings.TrimSpace(kv[1])
if k == "mp" || k == "mountpoint" {
mountpoint = v
}
}
return source, mountpoint
}
func parseOnbootValue(value interface{}) *bool {
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" {
return nil
}
if raw == "1" || strings.EqualFold(raw, "yes") || strings.EqualFold(raw, "true") {
val := true
return &val
}
if raw == "0" || strings.EqualFold(raw, "no") || strings.EqualFold(raw, "false") {
val := false
return &val
}
return nil
}
func (e *PulseToolExecutor) executeSearchResources(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
if !e.hasReadState() {
return NewTextResult("State provider not available."), nil
}
rawQuery, _ := args["query"].(string)
query := strings.TrimSpace(rawQuery)
if query == "" {
return NewErrorResult(fmt.Errorf("query is required")), nil
}
typeFilter, _ := args["type"].(string)
// Map resource_type to type for search
if typeFilter == "" {
typeFilter, _ = args["resource_type"].(string)
}
typeFilter = canonicalQuerySearchType(typeFilter)
statusFilter, _ := args["status"].(string)
limit := intArg(args, "limit", 20)
offset := intArg(args, "offset", 0)
if limit <= 0 {
limit = 20
}
if offset < 0 {
offset = 0
}
allowedTypes := map[string]bool{
"": true,
"agent": true,
"node": true,
"vm": true,
"system-container": true,
"app-container": true,
"docker-host": true,
"storage": true,
"physical-disk": true,
}
if !allowedTypes[typeFilter] {
return NewErrorResult(fmt.Errorf("invalid type: %s. Use agent, node, vm, system-container, app-container, docker-host, storage, or physical-disk (compatibility aliases system and storage-pool are also accepted)", typeFilter)), nil
}
// normalizeForSearch replaces common separators with spaces and splits at
// alpha↔numeric boundaries for fuzzy matching. This allows queries like
// "LXC112", "VM100", "CT201" to match VMID candidates ("112", "100", "201").
normalizeForSearch := func(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
s = strings.ReplaceAll(s, ".", " ")
// Split at alpha↔numeric boundaries: "lxc112" → "lxc 112"
var buf strings.Builder
buf.Grow(len(s) + 4)
for i, ch := range s {
if i > 0 {
prev := s[i-1]
cur := byte(ch)
if (prev >= 'a' && prev <= 'z' && cur >= '0' && cur <= '9') ||
(prev >= '0' && prev <= '9' && cur >= 'a' && cur <= 'z') {
buf.WriteByte(' ')
}
}
buf.WriteRune(ch)
}
return buf.String()
}
matchesQuery := func(query string, candidates ...string) bool {
queryNorm := normalizeForSearch(query)
queryWords := strings.Fields(queryNorm)
for _, candidate := range candidates {
if candidate == "" {
continue
}
candidateNorm := normalizeForSearch(candidate)
// Direct substring match (normalized)
if strings.Contains(candidateNorm, queryNorm) {
return true
}
// All query words must be present in candidate
if len(queryWords) > 0 {
allMatch := true
for _, word := range queryWords {
if !strings.Contains(candidateNorm, word) {
allMatch = false
break
}
}
if allMatch {
return true
}
}
}
return false
}
queryLower := strings.ToLower(query)
rs, err := e.readStateForControl()
if err != nil {
return NewErrorResult(err), nil
}
governance := newGovernedQueryMetadataResolver(rs)
dockerHostsByID := make(map[string]*unifiedresources.DockerHostView)
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
dockerHostsByID[host.ID()] = host
}
systemResources := []unifiedresources.Resource{}
vmResources := []unifiedresources.Resource{}
systemContainerResources := []unifiedresources.Resource{}
appContainerResources := []unifiedresources.Resource{}
storagePoolResources := []unifiedresources.Resource{}
physicalDiskResources := []unifiedresources.Resource{}
if e.unifiedResourceProvider != nil {
systemResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeAgent))
vmResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeVM))
systemContainerResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeSystemContainer))
appContainerResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypeAppContainer))
physicalDiskResources = sortedResourcesByName(e.unifiedResourceProvider.GetByType(unifiedresources.ResourceTypePhysicalDisk))
storagePoolResources = canonicalStoragePoolResources(e.unifiedResourceProvider)
}
// Build a set of connected agent hostnames for quick lookup
connectedAgentHostnames := make(map[string]bool)
if e.agentServer != nil {
for _, agent := range e.agentServer.GetConnectedAgents() {
connectedAgentHostnames[agent.Hostname] = true
}
}
matches := make([]ResourceMatch, 0, limit)
total := 0
addMatch := func(match ResourceMatch) {
if total < offset {
total++
return
}
if len(matches) >= limit {
total++
return
}
matches = append(matches, match)
total++
}
if typeFilter == "" || typeFilter == "agent" {
for _, resource := range systemResources {
status := string(resource.Status)
if !statusMatchesFilter(status, statusFilter) {
continue
}
candidates := canonicalAgentSearchCandidates(resource)
if !matchesQuery(queryLower, candidates...) {
continue
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(candidates...),
Type: "agent",
ID: resource.ID,
Name: resourceDisplayName(resource),
Status: status,
Host: canonicalAgentHost(resource),
Platform: canonicalResourcePlatform(resource),
CommandAgentConnected: resourceCommandAgentConnected(resource, connectedAgentHostnames),
})
}
}
if typeFilter == "" || typeFilter == "node" {
for _, node := range rs.Nodes() {
status := string(node.Status())
if !statusMatchesFilter(status, statusFilter) {
continue
}
if !matchesQuery(queryLower, node.Name(), node.ID()) {
continue
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(node.Name(), node.ID()),
Type: "node",
Name: node.Name(),
Status: status,
CommandAgentConnected: commandConnectionObservation(connectedAgentHostnames, connectedAgentHostnames[node.Name()]),
})
}
}
if (typeFilter == "" || typeFilter == "vm") && len(vmResources) > 0 {
addCanonicalGuestSearchMatches("vm", vmResources, queryLower, statusFilter, governance, connectedAgentHostnames, matchesQuery, addMatch)
} else if typeFilter == "" || typeFilter == "vm" {
addGuestViewSearchMatches("vm", rs.VMs(), queryLower, statusFilter, governance, connectedAgentHostnames, matchesQuery, addMatch)
}
if (typeFilter == "" || typeFilter == "system-container") && len(systemContainerResources) > 0 {
addCanonicalGuestSearchMatches("system-container", systemContainerResources, queryLower, statusFilter, governance, connectedAgentHostnames, matchesQuery, addMatch)
} else if typeFilter == "" || typeFilter == "system-container" {
addGuestViewSearchMatches("system-container", rs.Containers(), queryLower, statusFilter, governance, connectedAgentHostnames, matchesQuery, addMatch)
}
if typeFilter == "" || typeFilter == "docker-host" {
for _, host := range rs.DockerHosts() {
if host == nil {
continue
}
status := string(host.Status())
if statusFilter != "" && !strings.EqualFold(status, statusFilter) {
continue
}
if !matchesQuery(queryLower, host.ID(), host.HostSourceID(), host.Hostname(), host.Name()) {
continue
}
hostID := strings.TrimSpace(host.HostSourceID())
if hostID == "" {
hostID = strings.TrimSpace(host.ID())
}
displayName := strings.TrimSpace(host.Name())
if displayName == "" {
displayName = strings.TrimSpace(host.Hostname())
}
hostName := strings.TrimSpace(host.Hostname())
if hostName == "" {
hostName = displayName
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(host.Hostname(), host.Name(), host.HostSourceID(), host.ID()),
Type: "docker-host",
ID: hostID,
Name: displayName,
Status: status,
Host: hostName,
})
}
}
if typeFilter == "" || typeFilter == "app-container" {
for _, resource := range appContainerResources {
status := canonicalAppContainerState(resource)
if !statusMatchesFilter(status, statusFilter) {
continue
}
canonicalID := canonicalAppContainerID(resource)
containerID := appContainerProviderID(resource)
candidates := []string{
resourceDisplayName(resource),
canonicalID,
containerID,
canonicalAppContainerHost(resource),
canonicalResourcePlatform(resource),
}
if resource.Docker != nil {
candidates = append(candidates, resource.Docker.Image)
for key, value := range resource.Docker.Labels {
candidates = append(candidates, key, value)
}
for _, network := range resource.Docker.Networks {
candidates = append(candidates, network.IPv4, network.IPv6, network.Name)
}
}
candidates = append(candidates, resource.Tags...)
if !matchesQuery(queryLower, candidates...) {
continue
}
summary := canonicalAppContainerSummaryFromResource(resource)
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(resourceDisplayName(resource), canonicalID, containerID),
Type: "app-container",
ID: summary.ID,
Name: summary.Name,
Status: summary.Status,
Host: summary.Host,
Platform: summary.Platform,
Image: summary.Image,
})
}
}
if (typeFilter == "" || typeFilter == "app-container") && len(appContainerResources) == 0 {
for _, container := range rs.DockerContainers() {
if container == nil {
continue
}
state := strings.TrimSpace(container.ContainerState())
if state == "" {
state = string(container.Status())
}
if statusFilter != "" && !strings.EqualFold(state, statusFilter) {
continue
}
containerID := strings.TrimSpace(container.ContainerID())
if containerID == "" {
containerID = strings.TrimSpace(container.ID())
}
// Build searchable candidates: name, ID, image, IPs
candidates := []string{container.Name(), containerID, container.Image()}
for _, network := range container.Networks() {
if network.IPv4 != "" {
candidates = append(candidates, network.IPv4)
}
if network.IPv6 != "" {
candidates = append(candidates, network.IPv6)
}
}
if !matchesQuery(queryLower, candidates...) {
continue
}
hostName := ""
if host := dockerHostsByID[strings.TrimSpace(container.ParentID())]; host != nil {
hostName = strings.TrimSpace(host.Hostname())
if hostName == "" {
hostName = strings.TrimSpace(host.Name())
}
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(container.Name(), container.ID(), containerID),
Type: "app-container",
ID: containerID,
Name: container.Name(),
Status: state,
Host: hostName,
Image: container.Image(),
})
}
}
if typeFilter == "" || typeFilter == "storage" {
for _, resource := range storagePoolResources {
status := string(resource.Status)
if !statusMatchesFilter(status, statusFilter) {
continue
}
candidates := canonicalStorageSearchCandidates(resource)
if !matchesQuery(queryLower, candidates...) {
continue
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(candidates...),
Type: "storage",
ID: resource.ID,
Name: resourceDisplayName(resource),
Status: status,
Host: canonicalStorageHost(resource),
Platform: canonicalResourcePlatform(resource),
})
}
}
if typeFilter == "" || typeFilter == "physical-disk" {
for _, resource := range physicalDiskResources {
status := string(resource.Status)
if !statusMatchesFilter(status, statusFilter) {
continue
}
summary := physicalDiskSummaryFromResource(resource)
candidates := []string{
resourceDisplayName(resource),
resource.ID,
summary.DevPath,
summary.Model,
summary.Serial,
summary.WWN,
summary.Type,
summary.Node,
strings.TrimSpace(resource.ParentName),
canonicalResourcePlatform(resource),
}
candidates = append(candidates, resource.Tags...)
if !matchesQuery(queryLower, candidates...) {
continue
}
addMatch(ResourceMatch{
GovernedResourceMetadata: governance.Resolve(resourceDisplayName(resource), resource.ID, summary.Serial, summary.DevPath),
Type: "physical-disk",
ID: resource.ID,
Name: resourceDisplayName(resource),
Status: status,
Host: summary.Node,
Platform: canonicalResourcePlatform(resource),
})
}
}
response := EmptyResourceSearchResponse()
response.Query = query
response.Matches = matches
response.Total = total
if offset > 0 || total > limit {
response.Pagination = &PaginationInfo{
Total: total,
Limit: limit,
Offset: offset,
}
}
// Register all found resources in the resolved context
// This enables action tools to validate that commands target legitimate resources
for _, match := range matches {
var reg ResourceRegistration
switch match.Type {
case "agent":
if resource, ok := findCanonicalResourceByID(systemResources, match.ID); ok {
reg, _ = canonicalAgentRegistration(resource)
} else {
reg = ResourceRegistration{
Kind: "agent",
ProviderUID: strings.TrimPrefix(match.ID, "agent:"),
Name: match.Name,
Aliases: []string{match.Name, match.ID, match.Host},
Executors: []ExecutorRegistration{{
ExecutorID: firstNonEmptyString(match.Host, match.Name, match.ID),
Adapter: match.Platform,
Actions: readOnlyResourceActions(),
Priority: 10,
}},
}
}
case "node":
reg = ResourceRegistration{
Kind: "node",
ProviderUID: match.Name, // Node name is the identifier used for routing
Name: match.Name,
Aliases: []string{match.Name},
HostName: match.Name,
LocationChain: []string{"node:" + match.Name},
Executors: []ExecutorRegistration{{
ExecutorID: match.Name,
Adapter: "direct",
Actions: []string{"query", "get", "exec"},
Priority: 10,
}},
}
case "vm":
if resource, ok := findCanonicalGuestResource(e.unifiedResourceProvider, "vm", match.ID); ok {
reg, _ = canonicalGuestRegistration("vm", resource)
} else if resource, ok := findCanonicalGuestResource(e.unifiedResourceProvider, "vm", match.Name); ok {
reg, _ = canonicalGuestRegistration("vm", resource)
} else {
reg = ResourceRegistration{
Kind: "vm",
ProviderUID: fmt.Sprintf("%d", match.VMID),
Name: match.Name,
Aliases: []string{match.Name, fmt.Sprintf("%d", match.VMID), match.ID},
HostUID: match.Node,
HostName: match.Node,
VMID: match.VMID,
Node: match.Node,
LocationChain: []string{"node:" + match.Node, "vm:" + match.Name},
Executors: []ExecutorRegistration{{
ExecutorID: match.Node,
Adapter: "qm",
Actions: guestExecutorActions(),
Priority: 10,
}},
}
}
case "system-container":
if resource, ok := findCanonicalGuestResource(e.unifiedResourceProvider, "system-container", match.ID); ok {
reg, _ = canonicalGuestRegistration("system-container", resource)
} else if resource, ok := findCanonicalGuestResource(e.unifiedResourceProvider, "system-container", match.Name); ok {
reg, _ = canonicalGuestRegistration("system-container", resource)
} else {
reg = ResourceRegistration{
Kind: "system-container",
ProviderUID: fmt.Sprintf("%d", match.VMID),
Name: match.Name,
Aliases: []string{match.Name, fmt.Sprintf("%d", match.VMID), match.ID},
HostUID: match.Node,
HostName: match.Node,
VMID: match.VMID,
Node: match.Node,
LocationChain: []string{"node:" + match.Node, "system-container:" + match.Name},
Executors: []ExecutorRegistration{{
ExecutorID: match.Node,
Adapter: "pct",
Actions: guestExecutorActions(),
Priority: 10,
}},
}
}
case "docker-host":
reg = ResourceRegistration{
Kind: "docker-host",
ProviderUID: match.ID,
Name: match.Name,
Aliases: []string{match.Name, match.ID, match.Host},
HostUID: match.Host,
HostName: match.Host,
LocationChain: []string{"docker-host:" + match.Host},
Executors: []ExecutorRegistration{{
ExecutorID: match.Host,
Adapter: "direct",
Actions: []string{"query", "get"},
Priority: 10,
}},
}
case "app-container":
reg = ResourceRegistration{
Kind: "app-container",
ProviderUID: match.ID, // Stable container/app provider ID
Name: match.Name,
Aliases: []string{match.Name, match.ID},
HostUID: match.Host,
HostName: match.Host,
}
if match.Platform == "docker" {
reg.LocationChain = []string{"docker-host:" + match.Host, "docker:" + match.Name}
reg.Executors = []ExecutorRegistration{{
ExecutorID: match.Host,
Adapter: "docker",
Actions: []string{"query", "get", "logs", "exec", "restart", "stop", "start"},
Priority: 10,
}}
} else if match.Platform == "truenas" {
reg.LocationChain = []string{"system:" + match.Host, "app:" + match.Name}
reg.Executors = []ExecutorRegistration{{
ExecutorID: match.Host,
Adapter: "truenas",
Actions: []string{"query", "get", "restart", "stop", "start"},
Priority: 10,
}}
}
case "storage":
if resource, ok := findCanonicalResourceByID(storagePoolResources, match.ID); ok {
reg, _ = canonicalStorageRegistration(resource)
} else {
reg = ResourceRegistration{
Kind: "storage",
ProviderUID: strings.TrimPrefix(match.ID, "storage:"),
Name: match.Name,
Aliases: []string{match.Name, match.ID},
Executors: []ExecutorRegistration{{
ExecutorID: firstNonEmptyString(match.Host, match.Name, match.ID),
Adapter: match.Platform,
Actions: readOnlyResourceActions(),
Priority: 10,
}},
}
}
case "physical-disk":
reg = ResourceRegistration{
Kind: "physical-disk",
ProviderUID: match.ID,
Name: match.Name,
Aliases: []string{match.Name, match.ID},
HostUID: match.Host,
HostName: match.Host,
}
default:
continue // Skip unknown types
}
e.registerResolvedResource(reg)
}
return NewJSONResult(response.NormalizeCollections()), nil
}
func (e *PulseToolExecutor) executeGetConnectionHealth(_ context.Context, _ map[string]interface{}) (CallToolResult, error) {
if e.connectionHealth == nil {
return NewTextResult("State provider not available."), nil
}
health := e.connectionHealth.GetConnectionHealth()
if len(health) == 0 {
return NewTextResult("No connection health data available."), nil
}
var connections []ConnectionStatus
connected := 0
disconnected := 0
for instanceID, isConnected := range health {
connections = append(connections, ConnectionStatus{
InstanceID: instanceID,
Connected: isConnected,
})
if isConnected {
connected++
} else {
disconnected++
}
}
response := EmptyConnectionHealthResponse()
response.Connections = connections
response.Total = len(connections)
response.Connected = connected
response.Disconnected = disconnected
return NewJSONResult(response.NormalizeCollections()), nil
}
// Helper to get int args with default
func intArg(args map[string]interface{}, key string, defaultVal int) int {
if v, ok := args[key]; ok {
switch val := v.(type) {
case int:
return val
case float64:
return int(val)
case int64:
return int(val)
}
}
return defaultVal
}
func statusMatchesFilter(actual, filter string) bool {
filter = strings.ToLower(strings.TrimSpace(filter))
if filter == "" || filter == "all" {
return true
}
actual = strings.ToLower(strings.TrimSpace(actual))
if actual == filter {
return true
}
// Compatibility mapping: ReadState normalizes guest lifecycle into "online/offline"
// while legacy Proxmox state often uses "running/stopped".
switch filter {
case "running":
return actual == "online"
case "online":
return actual == "running"
case "stopped":
return actual == "offline"
case "offline":
return actual == "stopped"
default:
return false
}
}