Pulse/internal/ai/resource_context_policy_model.go
rcourtman bdb212744c Remove the cloud_context_privacy dial; fix cloud context to a lean posture
Per maintainer decision: the cloud-context-privacy feature was bloat. The real
fix for the "useless Assistant on cloud" problem was the earlier sensitivity
recalibration (ordinary workloads = Internal, not redacted); the dial layered a
configurable knob on top of an already-solved problem, guarding mostly-non-secret
data on a destination the operator opted into, and demanded every model-bound path
stay dial-aware (a standing leak surface). The privacy control users actually
understand is the choice of model — cloud provider vs. local Ollama.

Removed entirely:
- AIConfig.CloudContextPrivacy dial + constants + GetCloudContextPrivacy /
  NormalizeCloudContextPrivacy, AND the now-dead legacy
  ShareOperationalContextWithCloud boolean + ShouldShareOperationalContextWithCloud
  (internal/config/ai.go); the config-load migration (persistence.go).
- Both fields from the /api/settings/ai request/response, validation, and sync
  (ai_handlers.go) + the JSON contract snapshots.
- The "Cloud model privacy" 3-option UI control, form field, presentation copy,
  and CloudContextPrivacy type (frontend), plus their tests.
- The dial branching in the seam: chat/service.go cloudPrivacyLevel,
  CloudContextPolicy.Level + local_only suppression + the localOnly directive
  (context_prefetch.go), the inventory resourceLabel dial logic (resource_context*),
  and the modelboundary RedactLocalOnlyResourcesOnly option.

Fixed lean posture (no setting): a cloud-routed model receives real infrastructure
context, with two always-on invariants enforced by the model-boundary sanitizer —
credentials are always stripped, and local-only/Restricted resources (the floor)
never leave the local trust boundary. Local (Ollama) always full. The sanitizer's
default is now the local-only floor; it remains the universal backstop installed on
EVERY model-bound path (chat, session compaction, discovery/report/analysis via the
shared helper). Kept the two standalone fixes from this effort: compaction now
routes through the sanitizer, and directives no longer inject the "redacted by
policy" placeholder.

Governance: ai-runtime contract rewritten to a fixed-posture rule; api-contracts /
frontend-primitives / agent-lifecycle / storage-recovery dial references removed.
Tests updated to the floor-only behavior (local-only redacted, Sensitive flows,
secrets stripped). Full internal/ai/..., config, api suites green; frontend
type-check + tests + lint green.
2026-06-09 09:43:21 +01:00

142 lines
4.7 KiB
Go

package ai
import (
"fmt"
"sort"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type unifiedResourcePolicyContext struct {
posture *unifiedresources.PolicyPostureSummary
externalModel bool
sensitivityCounts map[unifiedresources.ResourceSensitivity]int
routingCounts map[unifiedresources.ResourceRoutingScope]int
localOnlyCount int
redactionHints []unifiedresources.ResourceRedactionHint
redactionLabels []string
}
func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSummary, destinationModel string) unifiedResourcePolicyContext {
context := unifiedResourcePolicyContext{
externalModel: unifiedResourceContextUsesExternalModel(destinationModel),
posture: posture,
sensitivityCounts: map[unifiedresources.ResourceSensitivity]int{},
routingCounts: map[unifiedresources.ResourceRoutingScope]int{},
}
if posture == nil {
return context
}
if posture.SensitivityCounts != nil {
context.sensitivityCounts = posture.SensitivityCounts
}
if posture.RoutingCounts != nil {
context.routingCounts = posture.RoutingCounts
context.localOnlyCount = posture.RoutingCounts[unifiedresources.ResourceRoutingScopeLocalOnly]
}
context.redactionHints = resourcePolicyRedactionHintsFromCounts(posture.RedactionCounts)
context.redactionLabels = unifiedresources.ResourceRedactionLabelsFromHints(context.redactionHints)
return context
}
func unifiedResourceContextUsesExternalModel(destinationModel string) bool {
destinationModel = strings.TrimSpace(destinationModel)
if destinationModel == "" {
return false
}
provider, _ := config.ParseModelString(destinationModel)
return provider != config.AIProviderOllama
}
func (context unifiedResourcePolicyContext) hasGovernedResources() bool {
return context.posture != nil && context.posture.TotalResources > 0
}
func (context unifiedResourcePolicyContext) includeResourceDetails(resource unifiedresources.Resource) bool {
if !context.externalModel || resource.Policy == nil {
return true
}
return resource.Policy.Routing.Scope != unifiedresources.ResourceRoutingScopeLocalOnly
}
func (context unifiedResourcePolicyContext) filterDetailedResources(resources []unifiedresources.Resource) []unifiedresources.Resource {
if !context.externalModel || len(resources) == 0 {
return resources
}
filtered := make([]unifiedresources.Resource, 0, len(resources))
for _, resource := range resources {
if context.includeResourceDetails(resource) {
filtered = append(filtered, resource)
}
}
return filtered
}
func (context unifiedResourcePolicyContext) appendSummarySections(sections []string) []string {
if !context.hasGovernedResources() {
return sections
}
sections = append(sections, "\n### Data Governance")
sensitivityParts := unifiedresources.ResourcePolicySensitivitySummaryFromCounts(context.sensitivityCounts)
sections = append(sections, fmt.Sprintf("- Sensitivity: %s", strings.Join(sensitivityParts, ", ")))
routingParts := unifiedresources.ResourcePolicyRoutingSummaryFromCounts(context.routingCounts)
sections = append(sections, fmt.Sprintf("- Routing: %s", strings.Join(routingParts, ", ")))
sections = append(sections, fmt.Sprintf("- Local-only resources: %d", context.localOnlyCount))
if context.externalModel && context.localOnlyCount > 0 {
sections = append(sections, fmt.Sprintf("- External model handling: %d local-only resources are represented only in aggregate and omitted from detailed context.", context.localOnlyCount))
}
if len(context.redactionLabels) > 0 {
sections = append(sections, "\n### Policy Redaction Hints")
sections = append(sections, fmt.Sprintf("- Redactions in use: %s", strings.Join(context.redactionLabels, ", ")))
}
return sections
}
func resourcePolicyRedactionHintsFromCounts(counts map[unifiedresources.ResourceRedactionHint]int) []unifiedresources.ResourceRedactionHint {
if len(counts) == 0 {
return nil
}
hints := make([]unifiedresources.ResourceRedactionHint, 0, len(counts))
seen := make(map[unifiedresources.ResourceRedactionHint]struct{}, len(counts))
for _, hint := range unifiedresources.ResourceRedactionHintOrder {
if counts[hint] <= 0 {
continue
}
hints = append(hints, hint)
seen[hint] = struct{}{}
}
remaining := make([]string, 0, len(counts))
remainingHints := make(map[string]unifiedresources.ResourceRedactionHint, len(counts))
for hint, count := range counts {
if count <= 0 {
continue
}
if _, ok := seen[hint]; ok {
continue
}
key := string(hint)
remaining = append(remaining, key)
remainingHints[key] = hint
}
sort.Strings(remaining)
for _, key := range remaining {
hints = append(hints, remainingHints[key])
}
return hints
}