mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
Enforce data handling in AI context
This commit is contained in:
parent
fc5325ab80
commit
dc8208cc00
10 changed files with 408 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue