From 32d5972673734ccbd07b7635485afd952aa5ed6b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 8 Jun 2026 09:38:22 +0100 Subject: [PATCH] Share PII-free operational context with cloud Assistant when opted in Governed resources (every sensitive guest) were redacted to a terse summary on cloud-routed Assistant turns, so the Assistant went blind on cloud models -- generic non-answers for the majority of users who run cloud providers. Add AIConfig.ShareOperationalContextWithCloud (default false). When the operator opts in and the turn routes to an external provider, the chat prefetch path injects servicediscovery.FormatCloudSafeContext (service identity, access command, config/data/log paths, ports -- PII-free) in place of the terse governed redaction, and the model-bound resource sanitizer allow-lists those exact spans so they survive the provider boundary. Hostname/IP/alias/platform-id stay redacted regardless of the opt-in. When sharing is off on a cloud turn, the prefetch path instructs the Assistant to disclose the redaction and point at the setting instead of silently degrading the answer. Local (Ollama) routing is unaffected and always receives full context. Proof: internal/ai/chat/context_prefetch_cloud_context_test.go (opt-in => access path present, no hostname/IP; opt-out => governed redaction + transparency; model-bound sanitizer strips raw PII while the allow-listed cloud-safe span survives) and internal/config/ai_config_test.go. ai-runtime contract updated for the new opt-in behavior. --- .../v6/internal/subsystems/ai-runtime.md | 22 ++ internal/ai/chat/context_prefetch.go | 150 ++++++++++-- .../context_prefetch_cloud_context_test.go | 220 ++++++++++++++++++ internal/ai/chat/service.go | 14 +- internal/config/ai.go | 21 ++ internal/config/ai_config_test.go | 40 ++++ 6 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 internal/ai/chat/context_prefetch_cloud_context_test.go 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