diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index cbc4f8b4e..443a3365f 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -572,6 +572,12 @@ profile and assignment columns, but embedded table framing must route through may accept a preview-provided connected-resource override, but the live first-session runtime path must keep `/api/state` polling as the sole source of connected-system truth when no override is supplied. +7a. Keep lifecycle-neutral shared `internal/api/` changes from altering agent + setup, registration, install, or profile payloads by accident. AI runtime + or entitlement work that touches shared router or handler wiring must keep + lifecycle public routes, setup-token validation, and agent profile payloads + unchanged unless the lifecycle contract and its proofs are updated in the + same slice. 8. Keep `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx` oriented around the first monitored host. Install-token generation, governed command copy, and install instructions belong to the canonical diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 484013748..cf5119b17 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -148,6 +148,13 @@ runtime cost control, and shared AI transport surfaces. `frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx` may describe approval posture, but must not add Pro-badge suffixes or local commercial tracking around those runtime controls. +13. Keep Assistant control and Patrol paid runtime settings entitlement-effective + at every runtime boundary. Stored config may preserve autonomous, Patrol + auto-remediation, and alert-triggered analysis preferences so they come + back if entitlement returns, but API responses, chat executor startup, + restart, settings-update, request-clone paths, and Patrol execution must + clamp those values through runtime entitlements before exposing or enforcing + them. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5a3330e1f..60dbf076a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -628,6 +628,12 @@ the canonical monitored-system blocked payload. provider-catalog policy, and return that resolved model on the same shared `/api/settings/ai` payload instead of depending on frontend-supplied model defaults. +10. Keep AI settings paid-control fields entitlement-effective at the API + payload boundary. `/api/settings/ai` and `/api/settings/ai/update` may + preserve stored autonomous, Patrol auto-remediation, and alert-triggered + analysis preferences in config, but response payloads must expose only the + control level and paid Patrol settings currently allowed by runtime + entitlements. 10. Treat Patrol summary supporting metrics as readouts, not reinterpretations: when frontend consumers derive cards such as active findings, criticals, warnings, or fixes from the canonical payloads, those cards must stay numeric and must not synthesize new assessment labels like `Issues detected` or verification labels like `Partial verification` beneath the primary summary contract 11. Treat active Patrol runtime transport as compatible with factual activity surfaces: when the runtime is currently running, frontend consumers may surface in-progress activity context, but they must not replace the activity strip with a second assessment verdict derived from runtime state alone 12. Treat Patrol recency as a singular transport-driven fact: once header metadata, verification copy, or the findings footer already present the governed Patrol timing context, frontend summary consumers must not derive an extra timing pill from the same payloads inside the primary summary card diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d8504bfb8..5e7e141a7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -743,6 +743,12 @@ work extends shared components instead of creating new local variants. `settingsHeaderMeta.ts`, `settingsNavCatalog.ts`, and adjacent settings shells must flow through `frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts` +8. Keep shared settings-shell AI control copy capability-scoped rather than + upsell-scoped. `AIRuntimeControlsSection.tsx` may describe read-only, + approval-required, and autonomous action posture, but option labels and + helper text must avoid tier labels or broad "executes everything" wording; + paid capability availability belongs to entitlement-backed visibility and + lock state, not local select copy. instead of importing generic commercial presentation helpers directly into hosted settings route shells. Contextual settings feature gates must use capability-owned presentation diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index c4f86cf51..8ac938f26 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -465,6 +465,11 @@ shell clickable behind another overlay. model verification plus desktop Playwright proof that full-width shells distribute surplus width across peer columns instead of stretching only the `Resource` column. +6. Keep entitlement and runtime capability lookups on shared router/settings + hot paths bounded and request-local. AI control-level clamping in + `internal/api/router.go` may consult the already-wired runtime entitlement + service, but it must not add broad persistence scans, metrics fan-out, or + external network calls to protected settings or chat request paths. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3f6cab3e1..c1abc8296 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -630,6 +630,12 @@ state. load or save AI settings through shared helpers, any historical hosted quickstart model IDs must be cleared before adjacent surfaces read or re-emit that state. +17a. Keep adjacent AI paid-control state entitlement-effective on that shared + `internal/api/` boundary. Storage- and recovery-adjacent flows may preserve + stored Assistant or Patrol preferences in config, but they must not treat + stored autonomous, auto-remediation, or alert-triggered analysis settings + as active restore, recovery, or support capability unless the shared AI + runtime entitlement clamp exposes them as currently effective. When operators hover or focus pools versus physical disks, the storage summary must reuse one resolved active-series ID across card state and chart highlighting so pool-only cards demote cleanly during disk focus and diff --git a/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx b/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx index 02baa9bc8..e204eb266 100644 --- a/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx +++ b/frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx @@ -248,13 +248,13 @@ export const AIRuntimeControlsSection: Component labelClass="text-xs font-medium text-muted w-28 flex-shrink-0" selectBaseClass="flex-1 min-h-10 sm:min-h-9 px-2 py-2 text-sm border border-border rounded bg-surface" > - + diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 03c2b459f..820d505a9 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -203,7 +203,16 @@ describe('settings architecture guardrails', () => { expect(reportingPanelSource).not.toContain('Advanced Reporting (Pro)'); expect(aiRuntimeControlsSectionSource).toContain('showAutonomousControlOption'); expect(aiRuntimeControlsSectionSource).toContain("state.form.controlLevel === 'autonomous'"); + expect(aiRuntimeControlsSectionSource).toContain( + 'Controlled - Pulse Assistant asks before actions', + ); + expect(aiRuntimeControlsSectionSource).toContain( + 'Autonomous - Pulse Assistant can run eligible actions', + ); expect(aiRuntimeControlsSectionSource).not.toContain('without approval (Pro)'); + expect(aiRuntimeControlsSectionSource).not.toContain( + 'Pulse Assistant executes without approval', + ); }); it('keeps contextual settings feature gates free of retired commercial telemetry wrappers', () => { diff --git a/frontend-modern/src/utils/__tests__/aiControlLevelPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiControlLevelPresentation.test.ts index c3fe9aa38..616f78d3b 100644 --- a/frontend-modern/src/utils/__tests__/aiControlLevelPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiControlLevelPresentation.test.ts @@ -23,8 +23,8 @@ describe('aiControlLevelPresentation', () => { expect(getAIControlLevelBadgeClass('controlled')).toContain('bg-amber-100'); expect(getAIControlLevelBadgeClass('autonomous')).toContain('bg-red-100'); expect(getAIControlLevelDescription('read_only')).toContain('query and observe only'); - expect(getAIControlLevelDescription('controlled')).toContain('with approval'); - expect(getAIControlLevelDescription('autonomous')).toContain('without confirmation'); + expect(getAIControlLevelDescription('controlled')).toContain('after you approve'); + expect(getAIControlLevelDescription('autonomous')).toContain('without per-command approval'); }); it('returns canonical chat control-level presentation', () => { diff --git a/frontend-modern/src/utils/aiControlLevelPresentation.ts b/frontend-modern/src/utils/aiControlLevelPresentation.ts index 611ed0941..8601b5fce 100644 --- a/frontend-modern/src/utils/aiControlLevelPresentation.ts +++ b/frontend-modern/src/utils/aiControlLevelPresentation.ts @@ -38,9 +38,9 @@ export function getAIControlLevelBadgeClass(level: AIControlLevel): string { export function getAIControlLevelDescription(level: AIControlLevel): string { switch (level) { case 'controlled': - return 'Controlled mode: Pulse Assistant can execute commands and control VMs/containers with approval.'; + return 'Controlled mode: Pulse Assistant can run interactive actions only after you approve each one.'; case 'autonomous': - return 'Autonomous mode: Pulse Assistant executes commands and control actions without confirmation.'; + return 'Autonomous mode: Pulse Assistant can run eligible actions without per-command approval.'; default: return 'Read-only mode: Pulse Assistant can query and observe only.'; } diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index f3d348278..5f8ab39d5 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -72,6 +72,11 @@ type Config struct { // Optional: provides access to persisted recovery points (backups/snapshots). RecoveryPointsProvider tools.RecoveryPointsProvider + + // Optional: resolves the effective control level for current entitlements. + // Stored config may say autonomous, but runtime execution must use the + // entitlement-clamped level. + ControlLevelResolver func(*config.AIConfig) string } // Service provides direct AI chat without external sidecar @@ -96,6 +101,7 @@ type Service struct { contextPrefetcher *ContextPrefetcher budgetChecker func() error // Optional mid-run budget enforcement orgID string + controlLevelResolver func(*config.AIConfig) string activeMu sync.RWMutex activeExecutions map[string]map[*AgenticLoop]struct{} @@ -134,7 +140,7 @@ func NewService(cfg Config) *Service { } if cfg.AIConfig != nil { - execCfg.ControlLevel = tools.ControlLevel(cfg.AIConfig.GetControlLevel()) + execCfg.ControlLevel = resolveEffectiveControlLevel(cfg.ControlLevelResolver, cfg.AIConfig) execCfg.ProtectedGuests = cfg.AIConfig.GetProtectedGuests() } @@ -144,18 +150,38 @@ func NewService(cfg Config) *Service { executor.SetTelemetryCallback(NewAIMetricsTelemetryCallback()) return &Service{ - cfg: cfg.AIConfig, - dataDir: cfg.DataDir, - stateProvider: cfg.StateProvider, - readState: cfg.ReadState, - agentServer: cfg.AgentServer, - executor: executor, - orgID: strings.TrimSpace(cfg.OrgID), - activeExecutions: make(map[string]map[*AgenticLoop]struct{}), - questionExecutions: make(map[string]*AgenticLoop), + cfg: cfg.AIConfig, + dataDir: cfg.DataDir, + stateProvider: cfg.StateProvider, + readState: cfg.ReadState, + agentServer: cfg.AgentServer, + executor: executor, + orgID: strings.TrimSpace(cfg.OrgID), + controlLevelResolver: cfg.ControlLevelResolver, + activeExecutions: make(map[string]map[*AgenticLoop]struct{}), + questionExecutions: make(map[string]*AgenticLoop), } } +func resolveEffectiveControlLevel( + resolver func(*config.AIConfig) string, + cfg *config.AIConfig, +) tools.ControlLevel { + if resolver != nil { + if resolved := strings.TrimSpace(resolver(cfg)); config.IsValidControlLevel(resolved) { + return tools.ControlLevel(resolved) + } + } + if cfg == nil { + return tools.ControlLevelReadOnly + } + return tools.ControlLevel(cfg.GetControlLevel()) +} + +func (s *Service) effectiveControlLevelLocked() tools.ControlLevel { + return resolveEffectiveControlLevel(s.controlLevelResolver, s.cfg) +} + func (s *Service) registerActiveLoop(sessionID string, loop *AgenticLoop) { if s == nil || sessionID == "" || loop == nil { return @@ -360,7 +386,7 @@ func (s *Service) Restart(ctx context.Context, newCfg *config.AIConfig) error { // Update executor settings if s.executor != nil && s.cfg != nil { - s.executor.SetControlLevel(tools.ControlLevel(s.cfg.GetControlLevel())) + s.executor.SetControlLevel(s.effectiveControlLevelLocked()) s.executor.SetProtectedGuests(s.cfg.GetProtectedGuests()) } @@ -451,16 +477,19 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac overrideModel := strings.TrimSpace(req.Model) var executor *tools.PulseToolExecutor autonomousMode := false + effectiveControlLevel := tools.ControlLevelReadOnly s.mu.RLock() baseExecutor := s.executor unifiedResourceProvider := s.unifiedResourceProvider autonomousMode = s.autonomousMode + effectiveControlLevel = s.effectiveControlLevelLocked() if s.cfg != nil { configuredModel = strings.TrimSpace(s.cfg.GetChatModel()) } s.mu.RUnlock() if baseExecutor != nil { executor = baseExecutor.Clone() + executor.SetControlLevel(effectiveControlLevel) } // Per-request autonomous mode override (used by investigation to avoid @@ -769,10 +798,12 @@ func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, ca baseExecutor := s.executor unifiedResourceProvider := s.unifiedResourceProvider cfg := s.cfg + effectiveControlLevel := s.effectiveControlLevelLocked() s.mu.RUnlock() executor := baseExecutor if baseExecutor != nil { executor = baseExecutor.Clone() + executor.SetControlLevel(effectiveControlLevel) } // Determine model: use patrol model or fall back to chat model @@ -1314,8 +1345,9 @@ func (s *Service) UpdateControlSettings(cfg *config.AIConfig) { } s.mu.Lock() defer s.mu.Unlock() + s.cfg = cfg if s.executor != nil { - s.executor.SetControlLevel(tools.ControlLevel(cfg.GetControlLevel())) + s.executor.SetControlLevel(s.effectiveControlLevelLocked()) s.executor.SetProtectedGuests(cfg.GetProtectedGuests()) } } diff --git a/internal/ai/chat/service_additional_test.go b/internal/ai/chat/service_additional_test.go index f364ff7cc..a41ead995 100644 --- a/internal/ai/chat/service_additional_test.go +++ b/internal/ai/chat/service_additional_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" + "github.com/rcourtman/pulse-go-rewrite/internal/config" ) func TestServiceSettersAndAutonomousMode(t *testing.T) { @@ -36,4 +37,45 @@ func TestServiceExecuteCommand_NoExecutor(t *testing.T) { } } +func TestServiceEffectiveControlLevelUsesResolver(t *testing.T) { + service := NewService(Config{ + AIConfig: &config.AIConfig{ControlLevel: config.ControlLevelAutonomous}, + ControlLevelResolver: func(*config.AIConfig) string { + return config.ControlLevelReadOnly + }, + }) + + service.mu.RLock() + got := service.effectiveControlLevelLocked() + service.mu.RUnlock() + + if got != tools.ControlLevelReadOnly { + t.Fatalf("expected resolver-clamped control level %q, got %q", tools.ControlLevelReadOnly, got) + } +} + +func TestServiceUpdateControlSettingsRefreshesEffectiveConfig(t *testing.T) { + service := NewService(Config{ + AIConfig: &config.AIConfig{ControlLevel: config.ControlLevelReadOnly}, + ControlLevelResolver: func(cfg *config.AIConfig) string { + return config.EffectiveControlLevelForEntitlement(cfg.GetControlLevel(), false) + }, + }) + + next := &config.AIConfig{ControlLevel: config.ControlLevelAutonomous} + service.UpdateControlSettings(next) + + service.mu.RLock() + gotCfg := service.cfg + gotLevel := service.effectiveControlLevelLocked() + service.mu.RUnlock() + + if gotCfg != next { + t.Fatal("expected UpdateControlSettings to refresh the service config") + } + if gotLevel != tools.ControlLevelControlled { + t.Fatalf("expected autonomous setting to be clamped to %q, got %q", tools.ControlLevelControlled, gotLevel) + } +} + // TestPatrolServiceSessionLifecycle was removed: it tested the deleted chat/patrol.go bridge. diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 807d448fc..19797eb22 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -75,28 +75,29 @@ type AIService interface { // AIHandler handles all AI endpoints using direct AI integration type AIHandler struct { - stateMu sync.RWMutex - approvalStoreMu sync.Mutex - mtPersistence *config.MultiTenantPersistence - mtMonitor *monitoring.MultiTenantMonitor - defaultConfig *config.Config - defaultPersistence AIPersistence - hostedMode bool - defaultService AIService - agentServer *agentexec.Server - services map[string]AIService - servicesMu sync.RWMutex - serviceInitMu sync.RWMutex - serviceInit func(ctx context.Context, svc AIService) - defaultMonitor *monitoring.Monitor - unifiedStoreMu sync.RWMutex - unifiedStore *unified.UnifiedStore - unifiedStores map[string]*unified.UnifiedStore - readState unifiedresources.ReadState - recoveryManager *recoverymanager.Manager - approvalStore *approval.Store - approvalStoreDir string - approvalStoreStop context.CancelFunc + stateMu sync.RWMutex + approvalStoreMu sync.Mutex + mtPersistence *config.MultiTenantPersistence + mtMonitor *monitoring.MultiTenantMonitor + defaultConfig *config.Config + defaultPersistence AIPersistence + hostedMode bool + defaultService AIService + agentServer *agentexec.Server + services map[string]AIService + servicesMu sync.RWMutex + serviceInitMu sync.RWMutex + serviceInit func(ctx context.Context, svc AIService) + defaultMonitor *monitoring.Monitor + unifiedStoreMu sync.RWMutex + unifiedStore *unified.UnifiedStore + unifiedStores map[string]*unified.UnifiedStore + readState unifiedresources.ReadState + recoveryManager *recoverymanager.Manager + approvalStore *approval.Store + approvalStoreDir string + approvalStoreStop context.CancelFunc + controlLevelResolver func(context.Context, *config.AIConfig) string } // newChatService is the factory function for creating the AI service. @@ -280,6 +281,42 @@ func (h *AIHandler) SetServiceInitializer(initializer func(ctx context.Context, } } +// SetControlLevelResolver configures the entitlement-aware control-level +// resolver used by chat services when applying Assistant tool permissions. +func (h *AIHandler) SetControlLevelResolver( + resolver func(context.Context, *config.AIConfig) string, +) { + if h == nil { + return + } + h.stateMu.Lock() + defer h.stateMu.Unlock() + h.controlLevelResolver = resolver +} + +func (h *AIHandler) resolveControlLevel(ctx context.Context, cfg *config.AIConfig) string { + if h == nil { + if cfg == nil { + return config.ControlLevelReadOnly + } + return cfg.GetControlLevel() + } + + h.stateMu.RLock() + resolver := h.controlLevelResolver + h.stateMu.RUnlock() + + if resolver != nil { + if level := strings.TrimSpace(resolver(ctx, cfg)); config.IsValidControlLevel(level) { + return level + } + } + if cfg == nil { + return config.ControlLevelReadOnly + } + return cfg.GetControlLevel() +} + // GetService returns the AI service for the current context func (h *AIHandler) GetService(ctx context.Context) AIService { orgID := GetOrgID(ctx) @@ -386,6 +423,9 @@ func (h *AIHandler) initTenantService(ctx context.Context, orgID string) AIServi AgentServer: h.agentServer, ReadState: h.readStateForOrg(orgID), OrgID: orgID, + ControlLevelResolver: func(next *config.AIConfig) string { + return h.resolveControlLevel(tenantCtx, next) + }, } if recoveryManager != nil { chatCfg.RecoveryPointsProvider = tools.NewRecoveryPointsMCPAdapter(recoveryManager, orgID) @@ -589,6 +629,7 @@ func (h *AIHandler) Start(ctx context.Context, monitor *monitoring.Monitor) erro if orgID == "" { orgID = "default" } + serviceCtx := context.WithValue(backgroundContext(ctx), OrgIDContextKey, orgID) // Cache the monitor for use by Restart(). h.stateMu.Lock() @@ -603,6 +644,9 @@ func (h *AIHandler) Start(ctx context.Context, monitor *monitoring.Monitor) erro AgentServer: h.agentServer, ReadState: h.readStateForOrg(orgID), OrgID: orgID, + ControlLevelResolver: func(next *config.AIConfig) string { + return h.resolveControlLevel(serviceCtx, next) + }, } _, _, _, _, _, recoveryManager := h.stateRefs() if recoveryManager != nil { @@ -616,7 +660,7 @@ func (h *AIHandler) Start(ctx context.Context, monitor *monitoring.Monitor) erro h.servicesMu.Lock() h.defaultService = svc h.servicesMu.Unlock() - h.applyServiceInitializer(context.WithValue(context.Background(), OrgIDContextKey, orgID), svc) + h.applyServiceInitializer(serviceCtx, svc) // Initialize approval store for command approval workflow. h.ensureApprovalStore(dataDir) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 5cce453ce..c4f2d784e 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -1657,6 +1657,19 @@ func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) { h.onControlSettingsChange = callback } +// EffectiveControlLevel returns the Assistant control level that should be +// exposed or enforced for the current entitlement state. The stored setting can +// remain autonomous so it comes back if the entitlement returns, but runtime +// execution without ai_autofix must stay in approval mode. +func (h *AISettingsHandler) EffectiveControlLevel(ctx context.Context, settings *config.AIConfig) string { + if settings == nil { + return config.ControlLevelReadOnly + } + return settings.GetEffectiveControlLevel( + h.GetAIService(ctx).HasLicenseFeature(ai.FeatureAIAutoFix), + ) +} + // SetChatHandler sets the chat handler for investigation orchestration // This enables the patrol service to spawn chat sessions to investigate findings func (h *AISettingsHandler) SetChatHandler(chatHandler *AIHandler) { @@ -2326,6 +2339,9 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R // Determine if running in demo mode isDemo := mockmode.IsEnabled() triggerSettings := settings.GetPatrolEventTriggerSettings() + aiService := h.GetAIService(ctx) + hasAutoFixFeature := aiService.HasLicenseFeature(ai.FeatureAIAutoFix) + hasAlertAnalysisFeature := aiService.HasLicenseFeature(ai.FeatureAIAlerts) response := AISettingsResponse{ Enabled: settings.Enabled || isDemo, @@ -2340,8 +2356,8 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R // Patrol settings PatrolIntervalMinutes: settings.PatrolIntervalMinutes, PatrolEnabled: settings.PatrolEnabled, - PatrolAutoFix: settings.PatrolAutoFix, - AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis, + PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, + AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, @@ -2361,7 +2377,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R ConfiguredProviders: settings.GetConfiguredProviders(), CostBudgetUSD30d: settings.CostBudgetUSD30d, RequestTimeoutSeconds: settings.RequestTimeoutSeconds, - ControlLevel: settings.GetControlLevel(), + ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), ProtectedGuests: settings.GetProtectedGuests(), DiscoveryEnabled: settings.IsDiscoveryEnabled(), DiscoveryIntervalHours: settings.DiscoveryIntervalHours, @@ -2710,6 +2726,9 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt authMethod = string(config.AuthMethodAPIKey) } triggerSettings := settings.GetPatrolEventTriggerSettings() + aiService := h.GetAIService(r.Context()) + hasAutoFixFeature := aiService.HasLicenseFeature(ai.FeatureAIAutoFix) + hasAlertAnalysisFeature := aiService.HasLicenseFeature(ai.FeatureAIAlerts) // Return updated settings response := AISettingsResponse{ @@ -2724,8 +2743,8 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt OAuthConnected: settings.OAuthAccessToken != "", PatrolIntervalMinutes: settings.PatrolIntervalMinutes, PatrolEnabled: settings.PatrolEnabled, - PatrolAutoFix: settings.PatrolAutoFix, - AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis, + PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, + AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, @@ -2744,7 +2763,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt OpenAIBaseURL: settings.OpenAIBaseURL, ConfiguredProviders: settings.GetConfiguredProviders(), RequestTimeoutSeconds: settings.RequestTimeoutSeconds, - ControlLevel: settings.GetControlLevel(), + ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), ProtectedGuests: settings.GetProtectedGuests(), DiscoveryEnabled: settings.DiscoveryEnabled, DiscoveryIntervalHours: settings.DiscoveryIntervalHours, diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 8a81d9411..f33bb65d6 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -165,6 +165,37 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { } } +func TestAISettingsHandler_GetSettingsClampsPaidControlsToEntitlements(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + aiCfg.ControlLevel = config.ControlLevelAutonomous + aiCfg.PatrolAutoFix = true + aiCfg.AlertTriggeredAnalysis = true + require.NoError(t, persistence.SaveAIConfig(*aiCfg)) + + handler := newTestAISettingsHandler(cfg, persistence, nil) + handler.defaultAIService.SetLicenseChecker(stubLicenseChecker{allow: false}) + + req := newLoopbackRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp AISettingsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, config.ControlLevelControlled, resp.ControlLevel) + require.False(t, resp.PatrolAutoFix) + require.False(t, resp.AlertTriggeredAnalysis) +} + func TestAISettingsHandler_GetAIService_TenantPatrolUsesCanonicalProviders(t *testing.T) { tmp := t.TempDir() mtp := config.NewMultiTenantPersistence(tmp) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 9bcb8b8e0..29572174a 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -73,6 +73,49 @@ func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T) } } +func TestContractAISettingsClampsPaidRuntimeControlsToEntitlements(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = "http://127.0.0.1:11434" + aiCfg.ControlLevel = config.ControlLevelAutonomous + aiCfg.PatrolAutoFix = true + aiCfg.AlertTriggeredAnalysis = true + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("save ai config: %v", err) + } + + handler := newTestAISettingsHandler(cfg, persistence, nil) + handler.defaultAIService.SetLicenseChecker(stubLicenseChecker{allow: false}) + + req := newLoopbackRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/settings/ai status = %d, body %s", rec.Code, rec.Body.String()) + } + + var resp AISettingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ControlLevel != config.ControlLevelControlled { + t.Fatalf("control level = %q, want %q", resp.ControlLevel, config.ControlLevelControlled) + } + if resp.PatrolAutoFix { + t.Fatal("patrol auto-remediation must be entitlement-clamped") + } + if resp.AlertTriggeredAnalysis { + t.Fatal("alert-triggered analysis must be entitlement-clamped") + } +} + func sortedVMChartKeys(values map[string]VMChartData) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/internal/api/router.go b/internal/api/router.go index 82407d11e..eecec43a9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -623,7 +623,9 @@ func (r *Router) setupRoutes() { cfg := r.aiHandler.GetAIConfig(ctx) if cfg != nil { svc.UpdateControlSettings(cfg) - log.Info().Str("control_level", cfg.GetControlLevel()).Msg("Updated AI control settings") + log.Info(). + Str("control_level", r.aiSettingsHandler.EffectiveControlLevel(ctx, cfg)). + Msg("Updated AI control settings") } } } diff --git a/internal/config/ai.go b/internal/config/ai.go index 88c9064a7..ccca58146 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -617,6 +617,28 @@ func (c *AIConfig) GetControlLevel() string { } } +// EffectiveControlLevelForEntitlement returns the control level that may be +// enforced for the current entitlement state. Stored autonomous preferences are +// preserved in config, but without the autonomous entitlement they run as +// controlled approval mode. +func EffectiveControlLevelForEntitlement(level string, autonomousAllowed bool) string { + cfg := AIConfig{ControlLevel: level} + normalized := cfg.GetControlLevel() + if normalized == ControlLevelAutonomous && !autonomousAllowed { + return ControlLevelControlled + } + return normalized +} + +// GetEffectiveControlLevel returns the AI control level that should be exposed +// or enforced for the current entitlement state. +func (c *AIConfig) GetEffectiveControlLevel(autonomousAllowed bool) string { + if c == nil { + return ControlLevelReadOnly + } + return EffectiveControlLevelForEntitlement(c.GetControlLevel(), autonomousAllowed) +} + // IsControlEnabled returns true if AI has any control capability beyond read-only func (c *AIConfig) IsControlEnabled() bool { level := c.GetControlLevel() diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index a2ad8876c..6f5201850 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -6,6 +6,48 @@ import ( "time" ) +func TestEffectiveControlLevelForEntitlement(t *testing.T) { + tests := []struct { + name string + level string + autonomousAllowed bool + want string + }{ + { + name: "autonomous allowed stays autonomous", + level: ControlLevelAutonomous, + autonomousAllowed: true, + want: ControlLevelAutonomous, + }, + { + name: "autonomous without entitlement becomes controlled", + level: ControlLevelAutonomous, + autonomousAllowed: false, + want: ControlLevelControlled, + }, + { + name: "controlled stays controlled without entitlement", + level: ControlLevelControlled, + autonomousAllowed: false, + want: ControlLevelControlled, + }, + { + name: "invalid stays fail closed", + level: "bad", + autonomousAllowed: true, + want: ControlLevelReadOnly, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EffectiveControlLevelForEntitlement(tt.level, tt.autonomousAllowed); got != tt.want { + t.Fatalf("EffectiveControlLevelForEntitlement(%q, %v) = %q, want %q", tt.level, tt.autonomousAllowed, got, tt.want) + } + }) + } +} + func TestAIConfig_IsConfigured(t *testing.T) { tests := []struct { name string