mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Clamp AI control settings to entitlements
This commit is contained in:
parent
99129d0c09
commit
f67f877f95
19 changed files with 371 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue