Clamp AI control settings to entitlements

This commit is contained in:
rcourtman 2026-04-30 12:38:17 +01:00
parent 99129d0c09
commit f67f877f95
19 changed files with 371 additions and 49 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -248,13 +248,13 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
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"
>
<option value="read_only">Read Only - Pulse Assistant can only observe</option>
<option value="read_only">Read-only - Pulse Assistant can observe only</option>
<option value="controlled">
Controlled - Pulse Assistant executes with your approval
Controlled - Pulse Assistant asks before actions
</option>
<Show when={showAutonomousControlOption()}>
<option value="autonomous">
Autonomous - Pulse Assistant executes without approval
Autonomous - Pulse Assistant can run eligible actions
</option>
</Show>
</FormSelect>

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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.';
}

View file

@ -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())
}
}

View file

@ -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.

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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 {

View file

@ -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")
}
}
}

View file

@ -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()

View file

@ -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