From 4ea83e39c192369055f8ef7c3c371b13df953b44 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 19 Mar 2026 00:53:18 +0000 Subject: [PATCH] Surface canonical policy governance posture --- .../v6/internal/subsystems/ai-runtime.md | 4 ++ .../subsystems/patrol-intelligence.md | 3 + .../internal/subsystems/unified-resources.md | 4 ++ internal/ai/resource_context.go | 23 ++++++- internal/ai/resource_context_test.go | 5 +- .../unifiedresources/code_standards_test.go | 20 ++++++ .../unifiedresources/policy_metadata_test.go | 25 +++++++ .../unifiedresources/policy_presentation.go | 68 +++++++++++++++++++ 8 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 internal/unifiedresources/policy_presentation.go diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index fa8bcc3b0..99e63ad46 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -105,6 +105,10 @@ policy-aware metadata contract. The AI runtime may summarize governed resource policy counts for context, and it must switch to `aiSafeSummary` when a resource is marked `local-only` instead of leaking raw resource names or local identifiers for restricted resources through ad hoc context formatting. +That governed context should also surface the canonical routing posture and +redaction hints that were derived from the shared policy model, so prompts +reflect the same sensitivity, routing, and scrub decisions that the runtime +uses for export boundaries instead of rebuilding privacy posture locally. That same policy boundary now applies to chat structured-mention prefetch and resource-summary formatting: mention resolution must consume canonical unified-resource policy metadata, skip discovery fan-out when governed diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 8b230dc30..896a7fc58 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -100,3 +100,6 @@ The backend Patrol and AI runtime summaries now also share `internal/unifiedresources/change_presentation.go` for the canonical change-kind and provenance mapping, so the same resource-model semantics drive both the backend summaries and the frontend presentation helpers. +The canonical shared AI resource context now also surfaces policy routing and +redaction hints from unified resources, so the Patrol page and resource drawer +see the same governance posture that the runtime uses for export boundaries. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c91256eb5..3df18535e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -373,6 +373,10 @@ redaction hints for hostname, IP, platform-identity, alias, and path-bearing surfaces. Downstream API, AI, and frontend consumers may read those fields, but they must not replace them with local sensitivity inference or ad hoc privacy heuristics. +The AI runtime now also uses the canonical policy presentation helpers to +surface those routing and redaction labels in shared context output, so the +same policy model is reflected in prompt summaries instead of being +re-described independently per surface. Canonical resources now carry first-class graph-expansion fields: `Capabilities` (bounded action definitions with approval levels), `Relationships` (typed inter-resource links with direction and confidence), and `RecentChanges` (typed diff --git a/internal/ai/resource_context.go b/internal/ai/resource_context.go index db019ec4c..d5ae4779e 100644 --- a/internal/ai/resource_context.go +++ b/internal/ai/resource_context.go @@ -106,6 +106,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s byResourceID[resource.ID] = resource } sensitivityCounts := make(map[unifiedresources.ResourceSensitivity]int) + routingCounts := make(map[unifiedresources.ResourceRoutingScope]int) localOnlyCount := 0 var redactionHints []unifiedresources.ResourceRedactionHint redactionHintSet := make(map[unifiedresources.ResourceRedactionHint]struct{}) @@ -115,6 +116,7 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s continue } sensitivityCounts[resource.Policy.Sensitivity]++ + routingCounts[resource.Policy.Routing.Scope]++ if resource.Policy.Routing.Scope == unifiedresources.ResourceRoutingScopeLocalOnly { localOnlyCount++ } @@ -133,11 +135,17 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } if len(sensitivityCounts) > 0 { sections = append(sections, "\n### Data Governance") - sections = append(sections, fmt.Sprintf("- Sensitivity: %d internal, %d sensitive, %d restricted", + sections = append(sections, fmt.Sprintf("- Sensitivity: %d public, %d internal, %d sensitive, %d restricted", + sensitivityCounts[unifiedresources.ResourceSensitivityPublic], sensitivityCounts[unifiedresources.ResourceSensitivityInternal], sensitivityCounts[unifiedresources.ResourceSensitivitySensitive], sensitivityCounts[unifiedresources.ResourceSensitivityRestricted], )) + sections = append(sections, fmt.Sprintf("- Routing: %d cloud-summary, %d local-first, %d local-only", + routingCounts[unifiedresources.ResourceRoutingScopeCloudSummary], + routingCounts[unifiedresources.ResourceRoutingScopeLocalFirst], + routingCounts[unifiedresources.ResourceRoutingScopeLocalOnly], + )) sections = append(sections, fmt.Sprintf("- Local-only resources: %d", localOnlyCount)) } } @@ -518,6 +526,19 @@ func (s *Service) buildUnifiedResourceContextForModel(destinationModel string) s } } + if len(redactionHints) > 0 { + sections = append(sections, "\n### Policy Redaction Hints") + labels := make([]string, 0, len(redactionHints)) + for _, hint := range redactionHints { + if label := unifiedresources.ResourceRedactionHintLabel(hint); label != "" { + labels = append(labels, label) + } + } + if len(labels) > 0 { + sections = append(sections, fmt.Sprintf("- Redactions in use: %s", strings.Join(labels, ", "))) + } + } + result := "\n\n" + strings.Join(sections, "\n") const maxContextSize = 50000 diff --git a/internal/ai/resource_context_test.go b/internal/ai/resource_context_test.go index 3608fec74..dbb68ed40 100644 --- a/internal/ai/resource_context_test.go +++ b/internal/ai/resource_context_test.go @@ -222,7 +222,10 @@ func TestBuildUnifiedResourceContext_FullContext(t *testing.T) { assertContains("## Unified Infrastructure View") assertContains("Total resources: 11 (Infrastructure: 5, Workloads: 6)") assertContains("Data Governance") - assertContains("Sensitivity: 5 internal, 6 sensitive, 0 restricted") + assertContains("Sensitivity: 0 public, 5 internal, 6 sensitive, 0 restricted") + assertContains("Routing: 5 cloud-summary, 6 local-first, 0 local-only") + assertContains("Policy Redaction Hints") + assertContains("Redactions in use: Alias, Hostname, IP Address, Platform ID") assertContains("Proxmox VE Nodes") assertContains("HAS AGENT") assertContains("NO AGENT") diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index e619260be..34388f8eb 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -264,6 +264,26 @@ func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { } } +func TestResourcePolicyPresentationUsesCanonicalLabels(t *testing.T) { + data, err := os.ReadFile(filepath.Join(".", "policy_presentation.go")) + if err != nil { + t.Fatalf("failed to read policy_presentation.go: %v", err) + } + source := string(data) + + requiredSnippets := []string{ + "ResourceSensitivityLabel(", + "ResourceRoutingScopeLabel(", + "ResourceRedactionHintLabel(", + "ResourcePolicyRedactionLabels(", + } + for _, snippet := range requiredSnippets { + if !strings.Contains(source, snippet) { + t.Fatalf("policy_presentation.go must contain %q", snippet) + } + } +} + func TestResourceTimelineStoreIndexesSupportFilteredReads(t *testing.T) { data, err := os.ReadFile(filepath.Join("store.go")) if err != nil { diff --git a/internal/unifiedresources/policy_metadata_test.go b/internal/unifiedresources/policy_metadata_test.go index 50fbb2cda..043f0e7a8 100644 --- a/internal/unifiedresources/policy_metadata_test.go +++ b/internal/unifiedresources/policy_metadata_test.go @@ -79,6 +79,31 @@ func TestRefreshPolicyMetadata_ClassifiesInfrastructureAsInternal(t *testing.T) } } +func TestResourcePolicyPresentationLabels(t *testing.T) { + if got := ResourceSensitivityLabel(ResourceSensitivityPublic); got != "Public" { + t.Fatalf("sensitivity label = %q, want %q", got, "Public") + } + if got := ResourceRoutingScopeLabel(ResourceRoutingScopeLocalOnly); got != "Local Only" { + t.Fatalf("routing label = %q, want %q", got, "Local Only") + } + if got := ResourceRedactionHintLabel(ResourceRedactionIPAddress); got != "IP Address" { + t.Fatalf("redaction label = %q, want %q", got, "IP Address") + } + + policy := &ResourcePolicy{ + Routing: ResourceRoutingPolicy{ + Redact: []ResourceRedactionHint{ + ResourceRedactionPath, + ResourceRedactionHostname, + }, + }, + } + got := ResourcePolicyRedactionLabels(policy) + if len(got) != 2 || got[0] != "Hostname" || got[1] != "Path" { + t.Fatalf("redaction labels = %#v, want [Hostname Path]", got) + } +} + func containsRedactionHint(hints []ResourceRedactionHint, want ResourceRedactionHint) bool { for _, hint := range hints { if hint == want { diff --git a/internal/unifiedresources/policy_presentation.go b/internal/unifiedresources/policy_presentation.go new file mode 100644 index 000000000..c17889d91 --- /dev/null +++ b/internal/unifiedresources/policy_presentation.go @@ -0,0 +1,68 @@ +package unifiedresources + +import "sort" + +// ResourceSensitivityLabel returns the canonical human-readable sensitivity label. +func ResourceSensitivityLabel(sensitivity ResourceSensitivity) string { + switch sensitivity { + case ResourceSensitivityPublic: + return "Public" + case ResourceSensitivityInternal: + return "Internal" + case ResourceSensitivitySensitive: + return "Sensitive" + case ResourceSensitivityRestricted: + return "Restricted" + default: + return "Unclassified" + } +} + +// ResourceRoutingScopeLabel returns the canonical human-readable routing scope label. +func ResourceRoutingScopeLabel(scope ResourceRoutingScope) string { + switch scope { + case ResourceRoutingScopeCloudSummary: + return "Cloud Summary" + case ResourceRoutingScopeLocalFirst: + return "Local First" + case ResourceRoutingScopeLocalOnly: + return "Local Only" + default: + return "Unrouted" + } +} + +// ResourceRedactionHintLabel returns the canonical human-readable redaction label. +func ResourceRedactionHintLabel(hint ResourceRedactionHint) string { + switch hint { + case ResourceRedactionHostname: + return "Hostname" + case ResourceRedactionIPAddress: + return "IP Address" + case ResourceRedactionPlatformID: + return "Platform ID" + case ResourceRedactionAlias: + return "Alias" + case ResourceRedactionPath: + return "Path" + default: + return string(hint) + } +} + +// ResourcePolicyRedactionLabels returns the canonical human-readable labels for a policy's redaction hints. +func ResourcePolicyRedactionLabels(policy *ResourcePolicy) []string { + if policy == nil || len(policy.Routing.Redact) == 0 { + return nil + } + labels := make([]string, 0, len(policy.Routing.Redact)) + for _, hint := range policy.Routing.Redact { + label := ResourceRedactionHintLabel(hint) + if label == "" { + continue + } + labels = append(labels, label) + } + sort.Strings(labels) + return labels +}