From 2ebe65bbc5ae820113718217eb3fe9155ba13da0 Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Tue, 3 Feb 2026 19:28:02 +0000
Subject: [PATCH] security: add scope checks to AI Patrol and agent profile
endpoints
- AI Patrol mutation endpoints (acknowledge, dismiss, suppress, snooze, resolve,
findings/note, suppressions/*) now require ai:execute scope to prevent
low-privilege tokens from blinding patrol by hiding/suppressing findings
- Agent profile admin endpoints (/api/admin/profiles/*) now require
settings:write scope to prevent low-privilege tokens from modifying
fleet-wide agent behavior
---
frontend-modern/src/api/discovery.ts | 22 ++++
.../src/components/Discovery/DiscoveryTab.tsx | 103 +++++++++++++++++-
.../src/components/Hosts/HostDrawer.tsx | 1 +
internal/ai/discovery_adapter.go | 19 ++++
internal/api/router.go | 38 ++++---
internal/monitoring/monitor.go | 14 ++-
internal/servicediscovery/deep_scanner.go | 14 +++
internal/servicediscovery/service.go | 59 +++++++++-
pkg/auth/permissions.go | 2 +
9 files changed, 244 insertions(+), 28 deletions(-)
diff --git a/frontend-modern/src/api/discovery.ts b/frontend-modern/src/api/discovery.ts
index 9b3f9f5f7..dd86e9b9d 100644
--- a/frontend-modern/src/api/discovery.ts
+++ b/frontend-modern/src/api/discovery.ts
@@ -237,3 +237,25 @@ export function getConfidenceLevel(confidence: number): {
}
return { label: 'Low confidence', color: 'text-gray-500 dark:text-gray-400' };
}
+
+/**
+ * Connected agent info from WebSocket
+ */
+export interface ConnectedAgent {
+ agent_id: string;
+ hostname: string;
+ version: string;
+ platform: string;
+ connected_at: string;
+}
+
+/**
+ * Get list of agents connected via WebSocket (for command execution)
+ */
+export async function getConnectedAgents(): Promise<{ count: number; agents: ConnectedAgent[] }> {
+ const response = await apiFetch('/api/ai/agents');
+ if (!response.ok) {
+ throw new Error('Failed to get connected agents');
+ }
+ return response.json();
+}
diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx
index eeb66b5d2..ac8c9f488 100644
--- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx
+++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx
@@ -8,6 +8,7 @@ import {
formatDiscoveryAge,
getCategoryDisplayName,
getConfidenceLevel,
+ getConnectedAgents,
} from '../../api/discovery';
import { GuestMetadataAPI } from '../../api/guestMetadata';
import { eventBus } from '../../stores/events';
@@ -23,6 +24,8 @@ interface DiscoveryTabProps {
customUrl?: string;
/** Called after a URL is saved or deleted so the parent can update its state */
onCustomUrlChange?: (url: string) => void;
+ /** Whether commands are enabled for this host (from host agent config) */
+ commandsEnabled?: boolean;
}
// Construct the resource ID in the same format the backend uses
@@ -94,6 +97,43 @@ export const DiscoveryTab: Component = (props) => {
}
);
+ // Fetch connected agents (for WebSocket command execution)
+ const [connectedAgents] = createResource(
+ async () => {
+ try {
+ return await getConnectedAgents();
+ } catch {
+ return { count: 0, agents: [] };
+ }
+ }
+ );
+
+ // Check if this host has a connected agent for command execution
+ // Matches backend logic in deep_scanner.go findAgentForHost()
+ const hasConnectedAgent = () => {
+ const agents = connectedAgents()?.agents || [];
+
+ // First try exact match on agent ID
+ if (agents.some(agent => agent.agent_id === props.hostId)) {
+ return true;
+ }
+
+ // Then try hostname match
+ if (agents.some(agent =>
+ agent.hostname === props.hostname ||
+ agent.hostname === props.hostId
+ )) {
+ return true;
+ }
+
+ // If only one agent connected, backend will use it (fallback)
+ if (agents.length === 1) {
+ return true;
+ }
+
+ return false;
+ };
+
// Fetch discovery data
const [discovery, { refetch, mutate }] = createResource(
() => ({ type: props.resourceType, host: props.hostId, id: props.resourceId }),
@@ -148,9 +188,18 @@ export const DiscoveryTab: Component = (props) => {
console.error('Discovery failed:', err);
// Extract error message for display
const message = err instanceof Error ? err.message : 'Discovery scan failed';
- // Provide helpful message if it's a connection issue
+ // Provide helpful, specific message based on the error and current state
if (message.includes('no connected agent')) {
- setScanError('No agent available. Enable "Pulse Commands" in Settings → Unified Agents for this host.');
+ // Check if commands are enabled to provide more specific guidance
+ if (props.commandsEnabled === false) {
+ setScanError('Commands not enabled. Enable "Pulse Commands" in Settings → Unified Agents for this host.');
+ } else if (props.commandsEnabled === true) {
+ // Commands enabled but no WebSocket connection - likely token scope issue
+ setScanError('Agent not connected for command execution. The API token may be missing the "agent:exec" scope. Check Settings → API Tokens.');
+ } else {
+ // Unknown state - provide general guidance
+ setScanError('No agent available for command execution. Ensure "Pulse Commands" is enabled in Settings → Unified Agents and the API token has "agent:exec" scope.');
+ }
} else {
setScanError(message);
}
@@ -420,6 +469,56 @@ export const DiscoveryTab: Component = (props) => {
Run a discovery scan to identify services and configurations
+
+ {/* Connection Status Warning - Show when commands are needed but not available */}
+
+
+
+
+
+
+
+
+
Commands not enabled
+
+ Discovery requires command execution. Enable "Pulse Commands" in{' '}
+ Settings → Unified Agents .
+
+
+
+
+
+
+
+
+
+
+
+
+
Agent not connected for commands
+
+ Commands are enabled, but the agent isn't connected via WebSocket. Check that the API token has the{' '}
+ agent:exec{' '}
+ scope in Settings → API Tokens .
+
+
+
+
+
+
+
+
+
+
+
+
+ Agent connected and ready for command execution
+
+
+
+
+
+
handleTriggerDiscovery(true)}
disabled={isScanning()}
diff --git a/frontend-modern/src/components/Hosts/HostDrawer.tsx b/frontend-modern/src/components/Hosts/HostDrawer.tsx
index 8c48278ee..cb84eafd9 100644
--- a/frontend-modern/src/components/Hosts/HostDrawer.tsx
+++ b/frontend-modern/src/components/Hosts/HostDrawer.tsx
@@ -350,6 +350,7 @@ export const HostDrawer: Component = (props) => {
guestId={props.host.id}
customUrl={props.customUrl}
onCustomUrlChange={(url) => props.onCustomUrlChange?.(props.host.id, url)}
+ commandsEnabled={props.host.commandsEnabled}
/>
diff --git a/internal/ai/discovery_adapter.go b/internal/ai/discovery_adapter.go
index 9bb6fdddb..3a4b0769b 100644
--- a/internal/ai/discovery_adapter.go
+++ b/internal/ai/discovery_adapter.go
@@ -173,10 +173,29 @@ func (a *discoveryStateAdapter) GetState() servicediscovery.StateSnapshot {
}
}
+ // Convert Hosts
+ hosts := make([]servicediscovery.Host, len(state.Hosts))
+ for i, h := range state.Hosts {
+ hosts[i] = servicediscovery.Host{
+ ID: h.ID,
+ Hostname: h.Hostname,
+ DisplayName: h.DisplayName,
+ Platform: h.Platform,
+ OSName: h.OSName,
+ OSVersion: h.OSVersion,
+ KernelVersion: h.KernelVersion,
+ Architecture: h.Architecture,
+ CPUCount: h.CPUCount,
+ Status: h.Status,
+ Tags: h.Tags,
+ }
+ }
+
return servicediscovery.StateSnapshot{
VMs: vms,
Containers: containers,
DockerHosts: dockerHosts,
+ Hosts: hosts,
}
}
diff --git a/internal/api/router.go b/internal/api/router.go
index 371a0eb4a..33c53c632 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -515,11 +515,12 @@ func (r *Router) setupRoutes() {
}
})
- // Config Profile Routes - Protected by Admin Auth and Pro License
+ // Config Profile Routes - Protected by Admin Auth, Settings Scope, and Pro License
+ // SECURITY: Require settings:write scope to prevent low-privilege tokens from modifying agent profiles
// r.configProfileHandler.ServeHTTP implements http.Handler, so we wrap it
- r.mux.Handle("/api/admin/profiles/", RequireAdmin(r.config, RequireLicenseFeature(r.licenseHandlers, license.FeatureAgentProfiles, func(w http.ResponseWriter, req *http.Request) {
+ r.mux.Handle("/api/admin/profiles/", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, RequireLicenseFeature(r.licenseHandlers, license.FeatureAgentProfiles, func(w http.ResponseWriter, req *http.Request) {
http.StripPrefix("/api/admin/profiles", r.configProfileHandler).ServeHTTP(w, req)
- })))
+ }))))
// System settings routes
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, req *http.Request) {
@@ -1421,19 +1422,22 @@ func (r *Router) setupRoutes() {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
- r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
- r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
- r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
+ // SECURITY: AI Patrol read endpoints - require ai:execute scope
+ r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
+ r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
+ // SECURITY: AI Patrol mutation endpoints - require ai:execute scope to prevent low-privilege tokens from
+ // dismissing, suppressing, or otherwise hiding findings. This prevents attackers from blinding AI Patrol.
+ r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleAcknowledgeFinding)))
// Dismiss and resolve don't require Pro license - users should be able to clear findings they can see
// This is especially important for users who accumulated findings before fixing the patrol-without-AI bug
- r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleDismissFinding))
- r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, r.aiSettingsHandler.HandleSetFindingNote))
- r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, r.aiSettingsHandler.HandleSuppressFinding))
- r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
- r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, r.aiSettingsHandler.HandleResolveFinding))
- r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory))
- // Suppression rules management - free with patrol
- r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
+ r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleDismissFinding)))
+ r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSetFindingNote)))
+ r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSuppressFinding)))
+ r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSnoozeFinding)))
+ r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleResolveFinding)))
+ r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRunHistory)))
+ // Suppression rules management - require scope to prevent low-privilege tokens from creating suppression rules
+ r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
r.aiSettingsHandler.HandleGetSuppressionRules(w, req)
@@ -1442,9 +1446,9 @@ func (r *Router) setupRoutes() {
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
- }))
- r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule))
- r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings))
+ })))
+ r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleDeleteSuppressionRule)))
+ r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetDismissedFindings)))
// Patrol Autonomy - monitor/approval free, assisted/full require Pro (enforced in handlers)
r.mux.HandleFunc("/api/ai/patrol/autonomy", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go
index e49e3a706..ec326b7c7 100644
--- a/internal/monitoring/monitor.go
+++ b/internal/monitoring/monitor.go
@@ -8965,6 +8965,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
contentSuccess := 0 // Number of successful storage content fetches
contentFailures := 0 // Number of failed storage content fetches
storageQueryErrors := 0 // Number of nodes where storage list could not be queried
+ hadPermissionError := false // Track if any permission errors occurred this cycle
storagePreserveNeeded := map[string]struct{}{}
storageSuccess := map[string]struct{}{}
@@ -9051,6 +9052,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
// Check if this is a permission error
if strings.Contains(errStr, "403") || strings.Contains(errStr, "401") ||
strings.Contains(errStr, "permission") || strings.Contains(errStr, "forbidden") {
+ hadPermissionError = true
m.mu.Lock()
m.backupPermissionWarnings[instanceName] = "Missing PVEDatastoreAdmin permission on /storage. Run: pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin"
m.mu.Unlock()
@@ -9072,11 +9074,6 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
continue
}
- // Clear any previous permission warning on success
- m.mu.Lock()
- delete(m.backupPermissionWarnings, instanceName)
- m.mu.Unlock()
-
contentSuccess++
storageSuccess[storage.Storage] = struct{}{}
delete(storagePreserveNeeded, storage.Storage)
@@ -9212,6 +9209,13 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
m.alertManager.CheckBackups(pveStorage, pbsBackups, pmgBackups, guestsByKey, guestsByVMID)
}
+ // Clear permission warning if no permission errors occurred this cycle
+ if !hadPermissionError {
+ m.mu.Lock()
+ delete(m.backupPermissionWarnings, instanceName)
+ m.mu.Unlock()
+ }
+
log.Debug().
Str("instance", instanceName).
Int("count", len(allBackups)).
diff --git a/internal/servicediscovery/deep_scanner.go b/internal/servicediscovery/deep_scanner.go
index 9b745c7c6..04e76a0e4 100644
--- a/internal/servicediscovery/deep_scanner.go
+++ b/internal/servicediscovery/deep_scanner.go
@@ -370,6 +370,20 @@ func (s *DeepScanner) getTargetID(resourceType ResourceType, resourceID string)
func (s *DeepScanner) findAgentForHost(hostID, hostname string) string {
agents := s.executor.GetConnectedAgents()
+ log.Debug().
+ Str("hostID", hostID).
+ Str("hostname", hostname).
+ Int("connected_agents", len(agents)).
+ Msg("Finding agent for host")
+
+ // Log connected agents for debugging
+ for _, agent := range agents {
+ log.Debug().
+ Str("agent_id", agent.AgentID).
+ Str("agent_hostname", agent.Hostname).
+ Msg("Connected agent")
+ }
+
// First try exact match on agent ID
for _, agent := range agents {
if agent.AgentID == hostID {
diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go
index d57e392a9..bc96fd30b 100644
--- a/internal/servicediscovery/service.go
+++ b/internal/servicediscovery/service.go
@@ -80,6 +80,22 @@ type StateSnapshot struct {
Containers []Container
DockerHosts []DockerHost
KubernetesClusters []KubernetesCluster
+ Hosts []Host
+}
+
+// Host represents a host system (via host-agent).
+type Host struct {
+ ID string
+ Hostname string
+ DisplayName string
+ Platform string // e.g., "linux", "darwin", "windows"
+ OSName string // e.g., "Unraid", "Ubuntu", "Debian"
+ OSVersion string
+ KernelVersion string
+ Architecture string // e.g., "amd64", "arm64"
+ CPUCount int
+ Status string
+ Tags []string
}
// VM represents a virtual machine.
@@ -1125,11 +1141,15 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
// Run deep scan if scanner is available
var scanResult *ScanResult
+ var scanError error
if s.scanner != nil {
- var err error
- scanResult, err = s.scanner.Scan(ctx, req)
- if err != nil {
- log.Warn().Err(err).Str("id", resourceID).Msg("Deep scan failed, using metadata only")
+ scanResult, scanError = s.scanner.Scan(ctx, req)
+ if scanError != nil {
+ log.Warn().
+ Err(scanError).
+ Str("id", resourceID).
+ Str("resource_type", string(req.ResourceType)).
+ Msg("Deep scan failed, falling back to metadata-only analysis. For full discovery, ensure the host agent is connected with commands enabled.")
}
}
@@ -1222,6 +1242,19 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
Msg("Parsed Docker bind mounts from on-demand discovery")
}
}
+ } else if scanError != nil {
+ // Add note to reasoning when we couldn't run commands
+ metadataNote := "[Note: Discovery was limited to metadata-only analysis because command execution was unavailable. "
+ if strings.Contains(scanError.Error(), "no connected agent") {
+ metadataNote += "To enable full discovery with command execution, ensure the host agent has 'Pulse Commands' enabled in Settings → Unified Agents.]"
+ } else {
+ metadataNote += "Error: " + scanError.Error() + "]"
+ }
+ if discovery.AIReasoning != "" {
+ discovery.AIReasoning = metadataNote + " " + discovery.AIReasoning
+ } else {
+ discovery.AIReasoning = metadataNote
+ }
}
// Preserve user notes from existing discovery
@@ -1294,6 +1327,24 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
break
}
}
+ case ResourceTypeHost:
+ for _, host := range state.Hosts {
+ if host.ID == req.ResourceID || host.Hostname == req.ResourceID || host.ID == req.HostID {
+ metadata["hostname"] = host.Hostname
+ metadata["display_name"] = host.DisplayName
+ metadata["platform"] = host.Platform
+ metadata["os_name"] = host.OSName
+ metadata["os_version"] = host.OSVersion
+ metadata["kernel_version"] = host.KernelVersion
+ metadata["architecture"] = host.Architecture
+ metadata["cpu_count"] = host.CPUCount
+ metadata["status"] = host.Status
+ if len(host.Tags) > 0 {
+ metadata["tags"] = host.Tags
+ }
+ break
+ }
+ }
}
return metadata
diff --git a/pkg/auth/permissions.go b/pkg/auth/permissions.go
index 5908792db..3020342d7 100644
--- a/pkg/auth/permissions.go
+++ b/pkg/auth/permissions.go
@@ -15,4 +15,6 @@ const (
ResourceNodes = "nodes"
ResourceUsers = "users"
ResourceLicense = "license"
+ ResourceAI = "ai"
+ ResourceDiscovery = "discovery"
)