From dc8208cc0003efec361cf2b3df033c8136fabb2c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Apr 2026 20:41:13 +0100 Subject: [PATCH] Enforce data handling in AI context --- .../v6/internal/subsystems/ai-runtime.md | 5 + .../internal/subsystems/security-privacy.md | 1 + .../internal/subsystems/unified-resources.md | 5 + internal/ai/resource_context.go | 41 +++-- internal/ai/resource_context_policy_model.go | 39 ++++- .../ai/resource_context_policy_model_test.go | 48 +++++- internal/ai/resource_context_test.go | 142 +++++++++++++++++- .../unifiedresources/code_standards_test.go | 2 + .../unifiedresources/policy_metadata_test.go | 47 ++++++ .../unifiedresources/policy_presentation.go | 94 ++++++++++++ 10 files changed, 408 insertions(+), 16 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c4f14d762..5bf400171 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -80,6 +80,11 @@ runtime cost control, and shared AI transport surfaces. 3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes 4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops 5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider + External provider-bound unified-resource context must enforce the same + data-handling policy the export audit records: `local-only` resources are + represented only as aggregate posture and omitted from detailed prompt + sections, while sensitive alert text is scrubbed through the shared + unified-resource redaction helper before it reaches a non-local model. 6. Keep AI resource and incident context aligned with the canonical unified-resource timeline before falling back to patrol-local change detectors 7. Keep platform assistant read/control claims aligned with `docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md`. New diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 1c398674f..67dd8f1a7 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -106,6 +106,7 @@ visibility, and privacy controls to operators. 6. Keep the shared storage-directory and secure storage-file hardening helper aligned with the crypto manager plus control-plane magic-link key and store handling whenever runtime data-root ownership assumptions change. 7. Keep auth-env ingestion and shared fingerprint-verifier TLS defaults aligned whenever runtime auth loading or pinned-certificate transport behavior changes. 8. Keep the Data Handling settings surface neutral and non-commercial: it may show resource policy posture, local-only counts, and redaction coverage, but it must not advertise trials, upgrades, paid plans, or monitoring limits. +9. Keep operator-facing Data Handling posture aligned with runtime AI/context enforcement: `local-only` resource details must not be sent to external model prompts, and sensitive free-form alert text must use the shared resource-policy redaction helper before leaving the local trust boundary. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 1f9e3af87..a213c4ba6 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -135,6 +135,11 @@ The canonical AI-safe summary builder now owns the sensitivity-specific suffix phrases for `sensitive` and `restricted` resources, so the backend policy contract controls those strings instead of duplicating them inside the summary assembly branch. +Canonical policy presentation and exact-value redaction helpers are owned in +`internal/unifiedresources/policy_presentation.go`. AI, Patrol, alert, export, +and prompt consumers must use those helpers for governed resource names, +hostnames, IP addresses, platform IDs, aliases, and paths instead of inventing +consumer-local scrubbers. Canonical policy posture aggregation is owned here as well. Resource API payloads may expose a camelCase transport projection, but the counts must be derived from `internal/unifiedresources/policy_posture.go` after canonical diff --git a/internal/ai/resource_context.go b/internal/ai/resource_context.go index 49debfd82..80c0f4ae8 100644 --- a/internal/ai/resource_context.go +++ b/internal/ai/resource_context.go @@ -102,14 +102,16 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s workloads := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetWorkloads()) allResources := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetAll()) policyPosture := unifiedresources.SummarizePolicyPosture(allResources) - policyContext := buildUnifiedResourcePolicyContext(policyPosture) + policyContext := buildUnifiedResourcePolicyContext(policyPosture, destinationModel) + detailedInfrastructure := policyContext.filterDetailedResources(infrastructure) + detailedWorkloads := policyContext.filterDetailedResources(workloads) byResourceID := make(map[string]unifiedresources.Resource, len(allResources)) for _, resource := range allResources { byResourceID[resource.ID] = resource } sections = policyContext.appendSummarySections(sections) - if len(infrastructure) > 0 { + if len(detailedInfrastructure) > 0 { sections = append(sections, "\n### Infrastructure (Nodes & Hosts)") sections = append(sections, "These are the physical/virtual machines that host workloads.") @@ -120,7 +122,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s k8sClusters := make([]unifiedresources.Resource, 0) k8sNodes := make([]unifiedresources.Resource, 0) - for _, resource := range infrastructure { + for _, resource := range detailedInfrastructure { switch { case resource.Type == unifiedresources.ResourceTypeK8sCluster: k8sClusters = append(k8sClusters, resource) @@ -224,7 +226,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s for _, host := range dockerHosts { containerCount := 0 runningCount := 0 - for _, workload := range workloads { + for _, workload := range detailedWorkloads { if workload.ParentID == nil || *workload.ParentID != host.ID { continue } @@ -288,12 +290,12 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } } - if len(workloads) > 0 { + if len(detailedWorkloads) > 0 { sections = append(sections, "\n### Workloads (VMs & Containers)") byParent := make(map[string][]unifiedresources.Resource) noParent := make([]unifiedresources.Resource, 0) - for _, workload := range workloads { + for _, workload := range detailedWorkloads { if workload.ParentID != nil && strings.TrimSpace(*workload.ParentID) != "" { byParent[*workload.ParentID] = append(byParent[*workload.ParentID], workload) continue @@ -301,8 +303,8 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s noParent = append(noParent, workload) } - infraMap := make(map[string]unifiedresources.Resource, len(infrastructure)) - for _, resource := range infrastructure { + infraMap := make(map[string]unifiedresources.Resource, len(detailedInfrastructure)) + for _, resource := range detailedInfrastructure { infraMap[resource.ID] = resource } @@ -362,12 +364,15 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s storagePools := make([]unifiedresources.Resource, 0) for _, resource := range unifiedresources.RefreshCanonicalMetadataSlice(urp.GetByType(unifiedresources.ResourceTypeStorage)) { + if !policyContext.includeResourceDetails(resource) { + continue + } if resource.Storage != nil && strings.EqualFold(strings.TrimSpace(resource.Storage.Topology), "dataset") { continue } storagePools = append(storagePools, resource) } - physicalDisks := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetByType(unifiedresources.ResourceTypePhysicalDisk)) + physicalDisks := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetByType(unifiedresources.ResourceTypePhysicalDisk))) if len(storagePools) > 0 || len(physicalDisks) > 0 { sections = append(sections, "\n### Storage") @@ -449,11 +454,18 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s if len(activeAlerts) > 0 { sections = append(sections, "\n### Resources with Active Alerts") + omittedLocalOnlyAlerts := 0 for _, alert := range activeAlerts { displayName := strings.TrimSpace(alert.ResourceName) + message := strings.TrimSpace(alert.Message) if resourceID := strings.TrimSpace(alert.ResourceID); resourceID != "" { if resource, ok := byResourceID[resourceID]; ok { + if !policyContext.includeResourceDetails(resource) { + omittedLocalOnlyAlerts++ + continue + } displayName = unifiedresources.ResourcePolicyLabel(resource.Name, resource.AISafeSummary, resource.Policy) + message = unifiedresources.ResourcePolicyRedactedText(message, resource) } else if displayName == "" { displayName = resourceID } @@ -463,7 +475,10 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } sections = append(sections, fmt.Sprintf("- **%s**: %s (%s)", - displayName, alert.Message, alert.Level)) + displayName, message, alert.Level)) + } + if omittedLocalOnlyAlerts > 0 { + sections = append(sections, fmt.Sprintf("- %d alerts on local-only resources omitted from external model context by policy.", omittedLocalOnlyAlerts)) } } @@ -528,7 +543,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } } - topCPU := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByCPU(3, nil)) + topCPU := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByCPU(3, nil))) if len(topCPU) > 0 { sections = append(sections, "\n### Top CPU Consumers") for i, resource := range topCPU { @@ -541,7 +556,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } } - topMem := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByMemory(3, nil)) + topMem := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByMemory(3, nil))) if len(topMem) > 0 { sections = append(sections, "\n### Top Memory Consumers") for i, resource := range topMem { @@ -554,7 +569,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } } - topDisk := unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByDisk(3, nil)) + topDisk := policyContext.filterDetailedResources(unifiedresources.RefreshCanonicalMetadataSlice(urp.GetTopByDisk(3, nil))) if len(topDisk) > 0 { sections = append(sections, "\n### Top Disk Usage") for i, resource := range topDisk { diff --git a/internal/ai/resource_context_policy_model.go b/internal/ai/resource_context_policy_model.go index f7317a60a..ecc82f860 100644 --- a/internal/ai/resource_context_policy_model.go +++ b/internal/ai/resource_context_policy_model.go @@ -5,11 +5,13 @@ import ( "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 @@ -17,8 +19,9 @@ type unifiedResourcePolicyContext struct { redactionLabels []string } -func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSummary) unifiedResourcePolicyContext { +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{}, @@ -41,10 +44,41 @@ func buildUnifiedResourcePolicyContext(posture *unifiedresources.PolicyPostureSu 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 @@ -58,6 +92,9 @@ func (context unifiedResourcePolicyContext) appendSummarySections(sections []str 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") diff --git a/internal/ai/resource_context_policy_model_test.go b/internal/ai/resource_context_policy_model_test.go index e3d5c0d4c..2e5c7a040 100644 --- a/internal/ai/resource_context_policy_model_test.go +++ b/internal/ai/resource_context_policy_model_test.go @@ -48,7 +48,7 @@ func TestBuildUnifiedResourcePolicyContext(t *testing.T) { }, }) - context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources)) + context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "") if !context.hasGovernedResources() { t.Fatal("expected governed posture") @@ -85,3 +85,49 @@ func TestBuildUnifiedResourcePolicyContext(t *testing.T) { t.Fatalf("expected canonical redaction labels, got %q", joined) } } + +func TestBuildUnifiedResourcePolicyContextExternalModel(t *testing.T) { + localOnly := unifiedresources.Resource{ + ID: "mail-1", + Name: "customer-mail", + Type: unifiedresources.ResourceTypePMG, + Status: unifiedresources.StatusWarning, + PMG: &unifiedresources.PMGData{Hostname: "mail.internal"}, + } + cloudSummary := unifiedresources.Resource{ + ID: "public-1", + Name: "public-node", + Type: unifiedresources.ResourceTypeAgent, + Status: unifiedresources.StatusOnline, + Tags: []string{"public"}, + } + resources := unifiedresources.RefreshCanonicalMetadataSlice([]unifiedresources.Resource{localOnly, cloudSummary}) + + context := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "openai:gpt-4o") + + if !context.externalModel { + t.Fatal("expected external model handling") + } + if context.includeResourceDetails(resources[0]) { + t.Fatal("expected local-only resource details to be omitted") + } + if !context.includeResourceDetails(resources[1]) { + t.Fatal("expected cloud-summary resource details to remain available") + } + if filtered := context.filterDetailedResources(resources); len(filtered) != 1 || filtered[0].ID != "public-1" { + t.Fatalf("filtered resources = %#v, want only public-1", filtered) + } + + joined := strings.Join(context.appendSummarySections(nil), "\n") + if !strings.Contains(joined, "External model handling: 1 local-only resources are represented only in aggregate and omitted from detailed context.") { + t.Fatalf("expected external handling summary, got %q", joined) + } + + localContext := buildUnifiedResourcePolicyContext(unifiedresources.SummarizePolicyPosture(resources), "ollama:llama3") + if localContext.externalModel { + t.Fatal("expected ollama destination to stay local") + } + if !localContext.includeResourceDetails(resources[0]) { + t.Fatal("expected local model context to include local-only resource details") + } +} diff --git a/internal/ai/resource_context_test.go b/internal/ai/resource_context_test.go index 5e0256fdc..e1f119eac 100644 --- a/internal/ai/resource_context_test.go +++ b/internal/ai/resource_context_test.go @@ -315,9 +315,87 @@ func TestBuildUnifiedResourceContextUsesAISafeSummaryForLocalOnlyResources(t *te } } +func TestBuildUnifiedResourceContextForExternalModelOmitsLocalOnlyResourceDetails(t *testing.T) { + restricted := unifiedresources.Resource{ + ID: "mail-1", + Name: "customer-mail-gateway", + Type: unifiedresources.ResourceTypePMG, + Status: unifiedresources.StatusWarning, + PMG: &unifiedresources.PMGData{ + Hostname: "mail-gateway.internal", + }, + } + publicNode := unifiedresources.Resource{ + ID: "public-1", + Name: "public-node", + Type: unifiedresources.ResourceTypeAgent, + Status: unifiedresources.StatusOnline, + Tags: []string{"public"}, + } + resources := unifiedresources.RefreshCanonicalMetadataSlice([]unifiedresources.Resource{restricted, publicNode}) + + stats := unifiedresources.ResourceStats{ + Total: 2, + ByType: map[unifiedresources.ResourceType]int{ + unifiedresources.ResourceTypePMG: 1, + unifiedresources.ResourceTypeAgent: 1, + }, + ByStatus: map[unifiedresources.ResourceStatus]int{ + unifiedresources.StatusWarning: 1, + unifiedresources.StatusOnline: 1, + }, + } + + mockURP := &mockUnifiedResourceProvider{ + getStatsFunc: func() unifiedresources.ResourceStats { return stats }, + getInfrastructureFunc: func() []unifiedresources.Resource { + return resources + }, + getAllFunc: func() []unifiedresources.Resource { + return resources + }, + getTopCPUFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource { + return []unifiedresources.Resource{restricted} + }, + } + + s := &Service{ + unifiedResourceProvider: mockURP, + alertProvider: &resourceContextAlertProvider{ + active: []AlertInfo{ + { + ResourceID: restricted.ID, + ResourceName: restricted.Name, + Message: "customer-mail-gateway at mail-gateway.internal is degraded", + Level: "warning", + }, + }, + }, + } + got := s.buildUnifiedResourceContextForModel("openai:gpt-4o") + + for _, raw := range []string{"customer-mail-gateway", "mail-gateway.internal", "mail gateway resource; status warning; local-only context"} { + if strings.Contains(got, raw) { + t.Fatalf("expected external context to omit local-only detail %q, got %q", raw, got) + } + } + if !strings.Contains(got, "Local-only resources: 1") { + t.Fatalf("expected aggregate local-only count, got %q", got) + } + if !strings.Contains(got, "External model handling: 1 local-only resources are represented only in aggregate and omitted from detailed context.") { + t.Fatalf("expected external model handling note, got %q", got) + } + if !strings.Contains(got, "1 alerts on local-only resources omitted from external model context by policy.") { + t.Fatalf("expected local-only alert omission note, got %q", got) + } + if strings.Contains(got, "Top CPU Consumers") { + t.Fatalf("expected local-only top consumers to be omitted, got %q", got) + } +} + func TestBuildUnifiedResourceContextUsesAISafeSummaryForSensitiveResources(t *testing.T) { sensitiveVM := unifiedresources.Resource{ - ID: "vm-1", + ID: "prod-west:101", Name: "finance-db", Type: unifiedresources.ResourceTypeVM, Status: unifiedresources.StatusOnline, @@ -376,6 +454,68 @@ func TestBuildUnifiedResourceContextUsesAISafeSummaryForSensitiveResources(t *te } } +func TestBuildUnifiedResourceContextRedactsSensitiveAlertMessageText(t *testing.T) { + sensitiveVM := unifiedresources.Resource{ + ID: "prod-west:101", + Name: "finance-db", + Type: unifiedresources.ResourceTypeVM, + Status: unifiedresources.StatusWarning, + Identity: unifiedresources.ResourceIdentity{ + Hostnames: []string{"finance-db.internal"}, + IPAddresses: []string{"10.10.0.5"}, + }, + Canonical: &unifiedresources.CanonicalIdentity{ + PlatformID: "prod-west:101", + Aliases: []string{"finance-db-primary"}, + }, + } + unifiedresources.RefreshCanonicalMetadata(&sensitiveVM) + + stats := unifiedresources.ResourceStats{ + Total: 1, + ByType: map[unifiedresources.ResourceType]int{ + unifiedresources.ResourceTypeVM: 1, + }, + ByStatus: map[unifiedresources.ResourceStatus]int{ + unifiedresources.StatusWarning: 1, + }, + } + + mockURP := &mockUnifiedResourceProvider{ + getStatsFunc: func() unifiedresources.ResourceStats { return stats }, + getWorkloadsFunc: func() []unifiedresources.Resource { + return []unifiedresources.Resource{sensitiveVM} + }, + getAllFunc: func() []unifiedresources.Resource { + return []unifiedresources.Resource{sensitiveVM} + }, + } + + s := &Service{ + unifiedResourceProvider: mockURP, + alertProvider: &resourceContextAlertProvider{ + active: []AlertInfo{ + { + ResourceID: sensitiveVM.ID, + ResourceName: sensitiveVM.Name, + Message: "finance-db at finance-db.internal / 10.10.0.5 on prod-west:101 is degraded", + Level: "critical", + }, + }, + }, + } + + got := s.buildUnifiedResourceContextForModel("anthropic:claude-3-5-sonnet-latest") + for _, raw := range []string{"finance-db", "finance-db.internal", "10.10.0.5", "prod-west:101"} { + if strings.Contains(got, raw) { + t.Fatalf("expected sensitive alert text to redact %q, got %q", raw, got) + } + } + if !strings.Contains(got, unifiedresources.ResourcePolicyRedactedLabel) { + t.Fatalf("expected redaction marker in alert context, got %q", got) + } +} + func TestBuildUnifiedResourceContextUsesAISafeSummaryForPathRedactedResources(t *testing.T) { storage := unifiedresources.Resource{ ID: "storage-1", diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 5d48eb762..0851a9988 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -562,6 +562,7 @@ func TestResourcePolicyLabelHelpersUsedByAIConsumers(t *testing.T) { }, filepath.Join("..", "ai", "resource_context.go"): { "unifiedresources.ResourcePolicyLabel(", + "unifiedresources.ResourcePolicyRedactedText(", "unifiedresources.ResourceDisplayName(", "unifiedresources.ResourceClusterName(", "unifiedresources.ResourceIPSummary(", @@ -577,6 +578,7 @@ func TestResourcePolicyLabelHelpersUsedByAIConsumers(t *testing.T) { "return ResourcePolicyRequiresGovernedSummary(policy)", "Policy: sensitivity=%s, routing=%s", "func ResourcePolicyRedactedValue(value string, policy *ResourcePolicy, hints ...ResourceRedactionHint) string", + "func ResourcePolicyRedactedText(value string, resource Resource) string", "const ResourcePolicyRedactedLabel = \"redacted by policy\"", "func ResourceRedactionLabelsFromHints(hints []ResourceRedactionHint) []string", "func ResourceClusterName(resource Resource) string", diff --git a/internal/unifiedresources/policy_metadata_test.go b/internal/unifiedresources/policy_metadata_test.go index 706cead01..e174b40b4 100644 --- a/internal/unifiedresources/policy_metadata_test.go +++ b/internal/unifiedresources/policy_metadata_test.go @@ -416,6 +416,53 @@ func TestResourcePolicyLabelHelpers(t *testing.T) { } } +func TestResourcePolicyRedactedText(t *testing.T) { + resource := Resource{ + ID: "vm:prod-west:101", + Name: "finance-db", + Type: ResourceTypeVM, + Identity: ResourceIdentity{ + Hostnames: []string{"finance-db.internal"}, + IPAddresses: []string{"10.10.0.5"}, + ClusterName: "prod-west", + }, + Canonical: &CanonicalIdentity{ + DisplayName: "finance-db", + PlatformID: "prod-west:101", + Aliases: []string{"finance-db-primary"}, + }, + Storage: &StorageMeta{ + Path: "/mnt/pve/finance-db", + }, + Policy: &ResourcePolicy{ + Sensitivity: ResourceSensitivitySensitive, + Routing: ResourceRoutingPolicy{ + Scope: ResourceRoutingScopeLocalFirst, + Redact: []ResourceRedactionHint{ + ResourceRedactionHostname, + ResourceRedactionIPAddress, + ResourceRedactionPlatformID, + ResourceRedactionAlias, + ResourceRedactionPath, + }, + }, + }, + } + + got := ResourcePolicyRedactedText( + "finance-db at finance-db.internal / 10.10.0.5 moved from prod-west:101 alias finance-db-primary path /mnt/pve/finance-db", + resource, + ) + for _, raw := range []string{"finance-db", "finance-db.internal", "10.10.0.5", "prod-west:101", "finance-db-primary", "/mnt/pve/finance-db"} { + if strings.Contains(got, raw) { + t.Fatalf("expected %q to be redacted from %q", raw, got) + } + } + if count := strings.Count(got, ResourcePolicyRedactedLabel); count < 5 { + t.Fatalf("expected redaction markers in %q", got) + } +} + func TestResourceRedactionLabelsFromHints(t *testing.T) { got := ResourceRedactionLabelsFromHints([]ResourceRedactionHint{ ResourceRedactionPath, diff --git a/internal/unifiedresources/policy_presentation.go b/internal/unifiedresources/policy_presentation.go index 8cef6ab20..befa28858 100644 --- a/internal/unifiedresources/policy_presentation.go +++ b/internal/unifiedresources/policy_presentation.go @@ -264,6 +264,47 @@ func ResourcePolicyRedactedValue(value string, policy *ResourcePolicy, hints ... return value } +// ResourcePolicyRedactedText replaces exact resource identifiers that the +// canonical resource policy marks for redaction. It is intentionally +// value-based so alert, prompt, and export summaries cannot leak the same raw +// hostname, IP, alias, platform ID, or path through nearby free-form text. +func ResourcePolicyRedactedText(value string, resource Resource) string { + value = strings.TrimSpace(value) + if value == "" || resource.Policy == nil { + return value + } + + candidates := resourcePolicyRedactionCandidates(resource) + if len(candidates) == 0 { + return value + } + + sort.Slice(candidates, func(i, j int) bool { + if len(candidates[i].value) == len(candidates[j].value) { + return candidates[i].value < candidates[j].value + } + return len(candidates[i].value) > len(candidates[j].value) + }) + + redacted := value + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + if _, ok := seen[candidate.value]; ok { + continue + } + seen[candidate.value] = struct{}{} + if !resourcePolicyShouldRedactCandidate(resource.Policy, candidate.hint) { + continue + } + if len([]rune(candidate.value)) < 3 { + continue + } + redacted = strings.ReplaceAll(redacted, candidate.value, ResourcePolicyRedactedLabel) + } + + return redacted +} + // ResourceDisplayName returns the canonical resource display name fallback. func ResourceDisplayName(resource Resource) string { if resource.Canonical != nil { @@ -332,3 +373,56 @@ func ResourcePolicyRequiresGovernedSummary(policy *ResourcePolicy) bool { } return len(policy.Routing.Redact) > 0 } + +type resourcePolicyRedactionCandidate struct { + hint ResourceRedactionHint + value string +} + +func resourcePolicyShouldRedactCandidate(policy *ResourcePolicy, hint ResourceRedactionHint) bool { + if policy == nil { + return false + } + if policy.Routing.Scope == ResourceRoutingScopeLocalOnly { + return true + } + return ResourcePolicyRedacts(policy, hint) +} + +func resourcePolicyRedactionCandidates(resource Resource) []resourcePolicyRedactionCandidate { + var candidates []resourcePolicyRedactionCandidate + add := func(hint ResourceRedactionHint, values ...string) { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + candidates = append(candidates, resourcePolicyRedactionCandidate{hint: hint, value: value}) + } + } + } + + add(ResourceRedactionHostname, resource.Name, canonicalHostname(resource)) + add(ResourceRedactionHostname, resource.Identity.Hostnames...) + add(ResourceRedactionIPAddress, resource.Identity.IPAddresses...) + + if resource.Canonical != nil { + add(ResourceRedactionHostname, resource.Canonical.DisplayName) + add(ResourceRedactionPlatformID, resource.Canonical.PlatformID, resource.Canonical.PrimaryID) + add(ResourceRedactionAlias, resource.Canonical.Aliases...) + } + + add(ResourceRedactionPlatformID, canonicalPlatformID(resource), resource.ID) + add(ResourceRedactionAlias, resource.Identity.ClusterName) + + if resource.Proxmox != nil { + add(ResourceRedactionHostname, resource.Proxmox.NodeName) + add(ResourceRedactionAlias, resource.Proxmox.ClusterName) + } + if resource.Kubernetes != nil { + add(ResourceRedactionPlatformID, resource.Kubernetes.ClusterID) + add(ResourceRedactionAlias, resource.Kubernetes.ClusterName, resource.Kubernetes.SourceName) + } + if resource.Storage != nil { + add(ResourceRedactionPath, resource.Storage.Path) + } + + return candidates +}