diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 9017658c4..5822e69a9 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -88,6 +88,28 @@ deriving an older display status from `workflowStatusHistory`. and `internal/ai/providers/ollama.go` is the only layer that turns it into the Ollama `keep_alive` request field. An empty configured value means Pulse omits `keep_alive` so the Ollama server default applies. + Cloud operational-context sharing is a runtime privacy option owned by this + path: `internal/config/ai.go` stores + `AIConfig.ShareOperationalContextWithCloud` (default false, surfaced through + `ShouldShareOperationalContextWithCloud`). The default keeps governed + resources reduced to the terse policy redaction on cloud-routed turns, which + is correct for privacy but leaves the Assistant unable to give + resource-specific guidance on cloud models. When the operator opts in AND the + turn routes to an external provider, governed-resource context built in + `internal/ai/chat/context_prefetch.go` must carry the PII-free operational + context from `servicediscovery.FormatCloudSafeContext` (service identity, + access command, config/data/log paths, port numbers) in place of the terse + governed summary, and the model-bound resource-policy sanitizer in + `internal/ai/chat/service.go` must allow-list those exact spans via + `modelboundary.AllowResourcePolicyText` so they are not re-stripped at the + provider boundary. Genuinely identifying fields — hostname, IP, bind address, + alias, and platform ID — must never be emitted by that cloud-safe path and + stay redacted regardless of the opt-in. Local (Ollama) routing is unaffected + and always receives full context. Transparency is mandatory: when governed + operational context is withheld because a cloud turn has sharing off, + `context_prefetch.go` must instruct the Assistant to disclose the redaction in + its reply and point at the `Share operational context with cloud models` + setting, rather than silently degrading the answer. 3. Add or change Pulse Assistant request flow through `internal/api/ai_handler.go`, `frontend-modern/src/api/ai.ts`, and `frontend-modern/src/api/aiChat.ts` Assistant session compaction is a runtime-backed session workflow, not a local waiting message, transcript-only UI action, or stubbed summarize diff --git a/internal/ai/chat/context_prefetch.go b/internal/ai/chat/context_prefetch.go index eaefd5a82..363a0d965 100644 --- a/internal/ai/chat/context_prefetch.go +++ b/internal/ai/chat/context_prefetch.go @@ -7,10 +7,30 @@ import ( "strings" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" + "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" ) +// CloudContextPolicy controls whether governed resource context may be shared +// with a cloud-routed model as PII-free operational context. Both fields must +// be true for the cloud-safe operational context to be injected; the zero value +// preserves the historical terse-redaction behavior for governed resources. +type CloudContextPolicy struct { + // CloudRouting reports that this turn routes to an external (cloud) provider, + // where governed resource identity is otherwise redacted to a terse summary. + CloudRouting bool + // ShareOperationalContext reports that the operator opted in via + // AIConfig.ShareOperationalContextWithCloud. + ShareOperationalContext bool +} + +// sharesCloudOperationalContext reports whether governed resources should have +// their PII-free operational context injected for this turn. +func (p CloudContextPolicy) sharesCloudOperationalContext() bool { + return p.CloudRouting && p.ShareOperationalContext +} + // ResourceMention represents a detected resource mention in a user message type ResourceMention struct { Name string @@ -44,6 +64,11 @@ type PrefetchedContext struct { Mentions []ResourceMention Discoveries []*tools.ResourceDiscoveryInfo Summary string // Formatted summary for AI consumption + // CloudSafeContextSpans holds the exact PII-free operational-context blocks + // injected for cloud routing. The caller must allow-list these spans through + // the model-bound resource-policy sanitizer so they are not re-stripped at + // the provider boundary. + CloudSafeContextSpans []string } // ContextPrefetcher proactively gathers context based on user message content @@ -90,9 +115,21 @@ func resourceRequiresReadOnlyGuidance(resourceType string, supportsControl bool) } // Prefetch gathers context only for explicit structured mentions selected by -// the user. Plain chat text is left untouched so the selected model decides -// whether it needs tools or more context. +// the user, using the default (cloud-redacting) policy. Plain chat text is left +// untouched so the selected model decides whether it needs tools or more +// context. func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, structuredMentions []StructuredMention) *PrefetchedContext { + return p.PrefetchWithCloudPolicy(ctx, message, structuredMentions, CloudContextPolicy{}) +} + +// PrefetchWithCloudPolicy is Prefetch with an explicit cloud-context policy. +// When the policy opts in for a cloud-routed turn, governed resources that would +// otherwise be reduced to a terse redacted summary instead carry PII-free +// operational context (service identity, access commands, paths, ports) via +// servicediscovery.FormatCloudSafeContext. Genuinely identifying fields +// (hostname, IP, alias, platform ID) are never emitted by that formatter and +// stay redacted at the model boundary. +func (p *ContextPrefetcher) PrefetchWithCloudPolicy(ctx context.Context, message string, structuredMentions []StructuredMention, cloudPolicy CloudContextPolicy) *PrefetchedContext { log.Info(). Bool("hasReadState", p.readState != nil). Bool("hasDiscoveryProvider", p.discoveryProvider != nil). @@ -139,7 +176,13 @@ func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, struct var discoveries []*tools.ResourceDiscoveryInfo if p.discoveryProvider != nil { for _, mention := range mentions { - if unifiedresources.ResourcePolicyRequiresGovernedSummary(mention.Policy) { + // Governed resources are normally summarized without discovery. When + // the operator opted in to cloud operational-context sharing for a + // cloud-routed turn, gather discovery anyway so the cloud-safe + // formatter can surface the PII-free access path instead of a terse + // redaction. PII is stripped by FormatCloudSafeContext itself. + if unifiedresources.ResourcePolicyRequiresGovernedSummary(mention.Policy) && + !cloudPolicy.sharesCloudOperationalContext() { continue } discovery, err := p.getOrTriggerDiscovery(ctx, mention) @@ -157,12 +200,13 @@ func (p *ContextPrefetcher) Prefetch(ctx context.Context, message string, struct } // Format the context summary - summary := p.formatContextSummary(mentions, discoveries) + summary, cloudSafeSpans := p.formatContextSummaryWithPolicy(mentions, discoveries, cloudPolicy) return &PrefetchedContext{ - Mentions: mentions, - Discoveries: discoveries, - Summary: summary, + Mentions: mentions, + Discoveries: discoveries, + Summary: summary, + CloudSafeContextSpans: cloudSafeSpans, } } @@ -639,11 +683,23 @@ func (p *ContextPrefetcher) getOrTriggerDiscovery(ctx context.Context, mention R } // formatContextSummary creates a formatted summary of the gathered context +// using the default (cloud-redacting) policy. func (p *ContextPrefetcher) formatContextSummary(mentions []ResourceMention, discoveries []*tools.ResourceDiscoveryInfo) string { + summary, _ := p.formatContextSummaryWithPolicy(mentions, discoveries, CloudContextPolicy{}) + return summary +} + +// formatContextSummaryWithPolicy formats the gathered context and, when the +// cloud policy opts in for a cloud-routed turn, replaces the terse governed +// redaction with PII-free operational context. It returns the summary plus the +// exact cloud-safe spans the caller must allow-list through the model-bound +// resource-policy sanitizer. +func (p *ContextPrefetcher) formatContextSummaryWithPolicy(mentions []ResourceMention, discoveries []*tools.ResourceDiscoveryInfo, cloudPolicy CloudContextPolicy) (string, []string) { if len(mentions) == 0 { - return "" + return "", nil } + var cloudSafeSpans []string var sb strings.Builder sb.WriteString("=== PULSE MONITORING DATA (AUTHORITATIVE) ===\n") sb.WriteString("This is verified data from Pulse monitoring sources. Canonical resource policy is enforced below.\n") @@ -659,14 +715,31 @@ func (p *ContextPrefetcher) formatContextSummary(mentions []ResourceMention, dis } for _, mention := range mentions { - if unifiedresources.ResourcePolicyRequiresGovernedSummary(mention.Policy) { - sb.WriteString(unifiedresources.FormatResourcePolicyGovernedSummary(mention.AISafeSummary, mention.Policy)) - continue - } - key := fmt.Sprintf("%s:%s:%s", tools.CanonicalDiscoveryResourceType(mention.ResourceType), mention.TargetID, mention.ResourceID) discovery, hasDiscovery := discoveryMap[key] + if unifiedresources.ResourcePolicyRequiresGovernedSummary(mention.Policy) { + // Cloud opt-in: surface PII-free operational context instead of the + // terse redaction so the Assistant can give resource-specific + // guidance on cloud models. FormatCloudSafeContext never emits + // hostname/IP/alias, so genuine PII stays withheld. + if cloudPolicy.sharesCloudOperationalContext() && hasDiscovery { + if cloudSafe := cloudSafeOperationalContext(discovery); cloudSafe != "" { + sb.WriteString(formatCloudSafeGovernedBlock(cloudSafe)) + cloudSafeSpans = append(cloudSafeSpans, cloudSafe) + continue + } + } + sb.WriteString(unifiedresources.FormatResourcePolicyGovernedSummary(mention.AISafeSummary, mention.Policy)) + // Transparency: when context was withheld because this turn routes to + // a cloud model and sharing is off, instruct the Assistant to say so + // and point at the opt-in. + if cloudPolicy.CloudRouting && !cloudPolicy.ShareOperationalContext { + sb.WriteString(cloudRedactionTransparencyDirective()) + } + continue + } + hint := readRoutingHintForMention(mention) // Docker containers get special treatment - show the full routing chain @@ -842,5 +915,56 @@ func (p *ContextPrefetcher) formatContextSummary(mentions []ResourceMention, dis sb.WriteString("\n") } + return sb.String(), cloudSafeSpans +} + +// cloudSafeOperationalContext bridges the prefetcher's discovery DTO to the +// canonical servicediscovery cloud-safe formatter. The result carries service +// identity, the access command, and config/data/log paths plus port numbers but +// never hostname, IP, alias, or bind-address — it is safe to send to a cloud +// model when the operator has opted in. +func cloudSafeOperationalContext(d *tools.ResourceDiscoveryInfo) string { + if d == nil { + return "" + } + ports := make([]servicediscovery.PortInfo, 0, len(d.Ports)) + for _, p := range d.Ports { + ports = append(ports, servicediscovery.PortInfo{Port: p.Port, Protocol: p.Protocol}) + } + return servicediscovery.FormatCloudSafeContext(&servicediscovery.ResourceDiscovery{ + ServiceType: d.ServiceType, + ServiceName: d.ServiceName, + ServiceVersion: d.ServiceVersion, + Category: servicediscovery.ServiceCategory(d.Category), + CLIAccess: d.CLIAccess, + ConfigPaths: d.ConfigPaths, + DataPaths: d.DataPaths, + LogPaths: d.LogPaths, + Ports: ports, + }) +} + +// formatCloudSafeGovernedBlock wraps the cloud-safe operational context with a +// heading that tells the model PII is still withheld for cloud routing. +func formatCloudSafeGovernedBlock(cloudSafe string) string { + var sb strings.Builder + sb.WriteString("## Governed resource (operational context shared for cloud)\n") + sb.WriteString(cloudSafe) + sb.WriteString("\nHostnames, IP addresses, aliases, and platform IDs remain withheld by canonical resource policy for cloud routing.\n\n") return sb.String() } + +// cloudRedactionTransparencyDirective instructs the Assistant to disclose, in +// its reply, that resource-specific operational context was withheld because the +// turn routes to a cloud model with sharing disabled, and to point at the +// opt-in. Pulse's privacy posture is only trustworthy if the redaction is +// visible to the user rather than silently degrading the answer. +func cloudRedactionTransparencyDirective() string { + return strings.Join([]string{ + "[Cloud routing transparency]", + "This resource's operational details (access commands, config/data/log paths, ports) were withheld because this chat routes to a cloud model and operational-context sharing with cloud models is off.", + "In your reply, tell the user you cannot give resource-specific steps for this reason, and that they can enable \"Share operational context with cloud models\" in Settings → AI (or use a local Ollama model) to get specific guidance. Do not fabricate the withheld details.", + "", + "", + }, "\n") +} diff --git a/internal/ai/chat/context_prefetch_cloud_context_test.go b/internal/ai/chat/context_prefetch_cloud_context_test.go new file mode 100644 index 000000000..dd85d2c6f --- /dev/null +++ b/internal/ai/chat/context_prefetch_cloud_context_test.go @@ -0,0 +1,220 @@ +package chat + +import ( + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// governedHomeAssistantMention returns a sensitive system-container mention with +// the same governed policy a real guest receives (local-first routing with +// hostname/IP/alias/path redaction), plus the cloud-routed @mention reference. +func governedHomeAssistantMention() ResourceMention { + return ResourceMention{ + Name: "homeassistant", + ResourceType: "system-container", + ResourceID: "101", + TargetID: "node1", + AISafeSummary: "system container resource; status online; redacted for cloud summary", + Policy: &unifiedresources.ResourcePolicy{ + Sensitivity: unifiedresources.ResourceSensitivitySensitive, + Routing: unifiedresources.ResourceRoutingPolicy{ + Scope: unifiedresources.ResourceRoutingScopeLocalFirst, + Redact: []unifiedresources.ResourceRedactionHint{ + unifiedresources.ResourceRedactionHostname, + unifiedresources.ResourceRedactionIPAddress, + unifiedresources.ResourceRedactionAlias, + unifiedresources.ResourceRedactionPath, + }, + }, + }, + } +} + +// homeAssistantDiscovery mirrors what Discovery captures for the HA LXC. The +// Hostname/IP-bearing fields are present on the DTO precisely so the test can +// prove the cloud-safe path never emits them. +func homeAssistantDiscovery() *tools.ResourceDiscoveryInfo { + return &tools.ResourceDiscoveryInfo{ + ID: "system-container:node1:101", + ResourceType: "system-container", + ResourceID: "101", + TargetID: "node1", + Hostname: "delly-ha-host", // PII: must never reach the cloud-safe context + ServiceType: "home-assistant", + ServiceName: "Home Assistant", + Category: "home-automation", + CLIAccess: "pct exec 101 -- docker exec homeassistant", + ConfigPaths: []string{"/config/configuration.yaml", "/config/automations.yaml"}, + LogPaths: []string{"/config/home-assistant.log"}, + Ports: []tools.DiscoveryPortInfo{ + {Port: 8123, Protocol: "tcp", Address: "192.168.0.101"}, // bind addr is PII + }, + } +} + +func TestPrefetcherCloudContext_OptInSharesAccessPathWithoutPII(t *testing.T) { + prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil) + + summary, spans := prefetcher.formatContextSummaryWithPolicy( + []ResourceMention{governedHomeAssistantMention()}, + []*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()}, + CloudContextPolicy{CloudRouting: true, ShareOperationalContext: true}, + ) + + // The operational access path reaches the model. + if !strings.Contains(summary, "pct exec 101 -- docker exec homeassistant") { + t.Fatalf("opt-in cloud summary must include the access path, got:\n%s", summary) + } + if !strings.Contains(summary, "/config/automations.yaml") { + t.Fatalf("opt-in cloud summary must include config paths, got:\n%s", summary) + } + if !strings.Contains(summary, "8123") { + t.Fatalf("opt-in cloud summary must include the port number, got:\n%s", summary) + } + + // PII never appears. + if strings.Contains(summary, "delly-ha-host") { + t.Fatalf("opt-in cloud summary leaked the hostname, got:\n%s", summary) + } + if strings.Contains(summary, "192.168.0.101") { + t.Fatalf("opt-in cloud summary leaked the bind IP, got:\n%s", summary) + } + + // The terse governed redaction is replaced, not appended. + if strings.Contains(summary, unifiedresources.ResourcePolicyGovernedSummaryFooter()) { + t.Fatalf("opt-in cloud summary must not fall back to the governed footer, got:\n%s", summary) + } + + // The exact cloud-safe span is returned for allow-listing. + if len(spans) != 1 { + t.Fatalf("expected exactly one cloud-safe span, got %d: %#v", len(spans), spans) + } + if !strings.Contains(spans[0], "pct exec 101 -- docker exec homeassistant") { + t.Fatalf("returned span must carry the access path, got %q", spans[0]) + } + if strings.Contains(spans[0], "delly-ha-host") || strings.Contains(spans[0], "192.168.0.101") { + t.Fatalf("returned span leaked PII, got %q", spans[0]) + } +} + +func TestPrefetcherCloudContext_OptOutKeepsRedaction(t *testing.T) { + prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil) + + summary, spans := prefetcher.formatContextSummaryWithPolicy( + []ResourceMention{governedHomeAssistantMention()}, + []*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()}, + CloudContextPolicy{CloudRouting: true, ShareOperationalContext: false}, + ) + + // Current redaction holds: no access path, no paths. + if strings.Contains(summary, "pct exec") { + t.Fatalf("opt-out cloud summary must withhold the access path, got:\n%s", summary) + } + if strings.Contains(summary, "/config/automations.yaml") { + t.Fatalf("opt-out cloud summary must withhold config paths, got:\n%s", summary) + } + if !strings.Contains(summary, unifiedresources.ResourcePolicyGovernedSummaryFooter()) { + t.Fatalf("opt-out cloud summary must keep the governed redaction, got:\n%s", summary) + } + + // Transparency: the Assistant is told to disclose the redaction + opt-in. + if !strings.Contains(summary, "Share operational context with cloud models") { + t.Fatalf("opt-out cloud summary must disclose the opt-in setting, got:\n%s", summary) + } + + if len(spans) != 0 { + t.Fatalf("opt-out must not return cloud-safe spans, got %#v", spans) + } +} + +func TestPrefetcherCloudContext_LocalRoutingUnaffected(t *testing.T) { + prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil) + + // Local routing (Ollama): not a cloud turn, so no transparency directive and + // no cloud-safe injection — behavior matches the historical governed path. + summary, spans := prefetcher.formatContextSummaryWithPolicy( + []ResourceMention{governedHomeAssistantMention()}, + []*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()}, + CloudContextPolicy{CloudRouting: false, ShareOperationalContext: true}, + ) + + if strings.Contains(summary, "Share operational context with cloud models") { + t.Fatalf("local routing must not emit the cloud transparency directive, got:\n%s", summary) + } + if !strings.Contains(summary, unifiedresources.ResourcePolicyGovernedSummaryFooter()) { + t.Fatalf("local routing must keep the governed prefetch redaction, got:\n%s", summary) + } + if len(spans) != 0 { + t.Fatalf("local routing must not return cloud-safe spans, got %#v", spans) + } +} + +// policiedResourceProvider is a minimal modelboundary.UnifiedResourceProvider +// returning one sensitive system container so the resource-policy sanitizer has +// real PII candidates to redact. +type policiedResourceProvider struct { + resource unifiedresources.Resource +} + +func (p *policiedResourceProvider) GetByType(t unifiedresources.ResourceType) []unifiedresources.Resource { + if t == unifiedresources.ResourceTypeSystemContainer { + return []unifiedresources.Resource{p.resource} + } + return nil +} + +func TestCloudSafeContextSurvivesModelBoundarySanitizer(t *testing.T) { + // A sensitive guest whose hostname, IP, and alias the policy must redact for + // cloud routing. + resource := unifiedresources.Resource{ + ID: "system-container:node1:101", + Type: unifiedresources.ResourceTypeSystemContainer, + Name: "homeassistant", + Identity: unifiedresources.ResourceIdentity{ + Hostnames: []string{"delly-ha-host"}, + IPAddresses: []string{"192.168.0.101"}, + }, + } + provider := &policiedResourceProvider{resource: resource} + + cloudSafe := cloudSafeOperationalContext(homeAssistantDiscovery()) + if cloudSafe == "" { + t.Fatal("expected a non-empty cloud-safe context") + } + + // The model request carries the cloud-safe span plus raw PII a model must not + // see. "homeassistant" appears in the span (docker exec target) AND is an + // alias the policy would redact, so the allow-list must protect it there. + userContent := cloudSafe + "\n\nRaw host: delly-ha-host at 192.168.0.101 (homeassistant)" + req := providers.ChatRequest{ + Messages: []providers.Message{{Role: "user", Content: userContent}}, + } + + sanitizer := modelboundary.RequestSanitizerForModel( + "openai:gpt-4o", + provider, + modelboundary.AllowResourcePolicyText(cloudSafe), + ) + if sanitizer == nil { + t.Fatal("expected a sanitizer for a cloud-routed model") + } + out := sanitizer(req).Messages[0].Content + + // The access path survives intact. + if !strings.Contains(out, "pct exec 101 -- docker exec homeassistant") { + t.Fatalf("sanitizer stripped the allow-listed access path, got:\n%s", out) + } + // Raw PII outside the protected span is redacted. + if strings.Contains(out, "delly-ha-host") { + t.Fatalf("sanitizer must redact the raw hostname, got:\n%s", out) + } + if strings.Contains(out, "192.168.0.101") { + t.Fatalf("sanitizer must redact the raw IP, got:\n%s", out) + } +} diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index d69977935..166f0c217 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -677,10 +677,16 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac Msg("[ChatService] Checking prefetcher") mentionsFound := false + var modelBoundaryAllowedCloudContext []string if prefetcher != nil { - prefetchCtx := prefetcher.Prefetch(ctx, req.Prompt, req.Mentions) + cloudContextPolicy := CloudContextPolicy{ + CloudRouting: modelboundary.ModelUsesExternalProvider(selectedModel), + ShareOperationalContext: cfgSnapshot.ShouldShareOperationalContextWithCloud(), + } + prefetchCtx := prefetcher.PrefetchWithCloudPolicy(ctx, req.Prompt, req.Mentions, cloudContextPolicy) if prefetchCtx != nil { mentionsFound = len(prefetchCtx.Mentions) > 0 + modelBoundaryAllowedCloudContext = prefetchCtx.CloudSafeContextSpans } if prefetchCtx != nil && prefetchCtx.Summary != "" { log.Info(). @@ -848,6 +854,12 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac if modelBoundaryAllowedInventoryContext != "" { sanitizerOptions = append(sanitizerOptions, modelboundary.AllowResourcePolicyText(modelBoundaryAllowedInventoryContext)) } + // Preserve the PII-free operational context injected for opted-in cloud + // turns so the resource-policy sanitizer does not re-strip it as if it + // were a raw identifier. FormatCloudSafeContext already excludes PII. + if len(modelBoundaryAllowedCloudContext) > 0 { + sanitizerOptions = append(sanitizerOptions, modelboundary.AllowResourcePolicyText(modelBoundaryAllowedCloudContext...)) + } loop.SetRequestSanitizer(modelboundary.RequestSanitizerForModel(attempt.Model, unifiedResourceProvider, sanitizerOptions...)) loop.SetSuppressProviderErrorEvents(true) loop.SetSessionFSM(sessionFSM) diff --git a/internal/config/ai.go b/internal/config/ai.go index da70404a3..269698354 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -103,6 +103,16 @@ type AIConfig struct { // Discovery settings - controls automatic infrastructure discovery DiscoveryEnabled bool `json:"discovery_enabled"` // Enable infrastructure discovery DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between automatic re-scans (0 = manual only, default: 0) + + // Cloud operational-context sharing - controls whether PII-free operational + // context (service identity, access commands, config/data/log paths, port + // numbers) for governed resources may be sent to CLOUD models. Default false: + // cloud-routed governed resources are redacted to a terse summary, which makes + // the Assistant unable to give resource-specific guidance on cloud models. + // Opting in shares the cloud-safe operational context while genuinely + // identifying fields (hostname, IP, alias, platform ID) stay redacted. Local + // (Ollama) models always receive full context and are unaffected by this flag. + ShareOperationalContextWithCloud bool `json:"share_operational_context_with_cloud,omitempty"` } // AIProvider constants @@ -899,3 +909,14 @@ func (c *AIConfig) GetDiscoveryInterval() time.Duration { } return time.Duration(c.DiscoveryIntervalHours) * time.Hour } + +// ShouldShareOperationalContextWithCloud reports whether PII-free operational +// context for governed resources may be sent to cloud models. Nil-safe and +// defaults to false so cloud routing keeps the terse governed redaction unless +// the operator explicitly opts in. +func (c *AIConfig) ShouldShareOperationalContextWithCloud() bool { + if c == nil { + return false + } + return c.ShareOperationalContextWithCloud +} diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index 2e8059301..0a87f6c3d 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -2,10 +2,50 @@ package config import ( "encoding/json" + "strings" "testing" "time" ) +func TestAIConfig_ShouldShareOperationalContextWithCloud(t *testing.T) { + if (*AIConfig)(nil).ShouldShareOperationalContextWithCloud() { + t.Fatalf("nil config must not share operational context with cloud") + } + + // Default (opt-out): a fresh config must not share operational context. + if NewDefaultAIConfig().ShouldShareOperationalContextWithCloud() { + t.Fatalf("default config must keep cloud operational-context sharing off") + } + if (&AIConfig{}).ShouldShareOperationalContextWithCloud() { + t.Fatalf("zero-value config must keep cloud operational-context sharing off") + } + + // Explicit opt-in is honored. + if !(&AIConfig{ShareOperationalContextWithCloud: true}).ShouldShareOperationalContextWithCloud() { + t.Fatalf("opt-in config must report cloud operational-context sharing on") + } + + // The flag round-trips through JSON and is omitted when off. + off, err := json.Marshal(&AIConfig{}) + if err != nil { + t.Fatalf("marshal off: %v", err) + } + if got := string(off); strings.Contains(got, "share_operational_context_with_cloud") { + t.Fatalf("off flag must be omitted from JSON, got %q", got) + } + on, err := json.Marshal(&AIConfig{ShareOperationalContextWithCloud: true}) + if err != nil { + t.Fatalf("marshal on: %v", err) + } + var decoded AIConfig + if err := json.Unmarshal(on, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !decoded.ShouldShareOperationalContextWithCloud() { + t.Fatalf("on flag must round-trip through JSON, got %q", string(on)) + } +} + func TestEffectiveControlLevelForEntitlement(t *testing.T) { tests := []struct { name string