Files
pulse/internal/ai/chat/context_prefetch_cloud_context_test.go
T
rcourtman 9ec52406ad Carry discovery freshness into the cloud-safe context
The pushed cloud-safe operational context told the model a resource's access
pattern, paths, and ports but not how OLD the discovery was — so the Assistant
could present a 2-week-old cached scan as current. For a monitoring assistant,
recency is the most important provenance signal.

FormatCloudSafeContext now appends "Last discovered: <age>" (via the existing
FormatDiscoveryAge helper, previously unused) when the timestamp is known, and
the push-path conversion (cloudSafeOperationalContext) carries UpdatedAt through.
A timestamp is non-identifying, so it adds no PII. Omitted when unknown.

- Tests: FormatCloudSafeContext freshness present/absent; cloudSafeOperationalContext
  carries UpdatedAt end of the push conversion (and still emits no PII).
- Contract: ai-runtime cloud-safe context must carry discovery age when known.

Follow-up: same freshness line on the local/full path (formatSingleDiscovery).
2026-06-09 10:46:10 +01:00

238 lines
9.3 KiB
Go

package chat
import (
"strings"
"testing"
"time"
"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_CloudSharesAccessPathWithoutPII(t *testing.T) {
prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil)
// On a cloud turn, governed resources surface the PII-free operational context:
// useful commands/paths/ports reach the model, identifying hostnames/IPs do not.
summary, spans := prefetcher.formatContextSummaryWithPolicy(
[]ResourceMention{governedHomeAssistantMention()},
[]*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()},
CloudContextPolicy{CloudRouting: 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_CloudWithoutDiscoveryKeepsGovernedSummary(t *testing.T) {
prefetcher := NewContextPrefetcher(newTestReadState(models.StateSnapshot{}), nil)
// A cloud turn with no discovery data falls back to the terse governed summary.
summary, spans := prefetcher.formatContextSummaryWithPolicy(
[]ResourceMention{governedHomeAssistantMention()},
nil,
CloudContextPolicy{CloudRouting: true},
)
if strings.Contains(summary, "pct exec") {
t.Fatalf("cloud-without-discovery must withhold the access path, got:\n%s", summary)
}
if !strings.Contains(summary, unifiedresources.ResourcePolicyGovernedSummaryFooter()) {
t.Fatalf("cloud-without-discovery must keep the governed redaction, got:\n%s", summary)
}
if len(spans) != 0 {
t.Fatalf("cloud-without-discovery 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 cloud-safe injection —
// behavior matches the historical governed path.
summary, spans := prefetcher.formatContextSummaryWithPolicy(
[]ResourceMention{governedHomeAssistantMention()},
[]*tools.ResourceDiscoveryInfo{homeAssistantDiscovery()},
CloudContextPolicy{CloudRouting: false},
)
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)
}
}
func TestCloudSafeOperationalContext_CarriesDiscoveryFreshness(t *testing.T) {
// The push-path conversion must carry UpdatedAt so the cloud-safe context
// can tell the model how old the discovery is.
d := homeAssistantDiscovery()
d.UpdatedAt = time.Now().Add(-2 * 24 * time.Hour)
out := cloudSafeOperationalContext(d)
if !strings.Contains(out, "Last discovered: 2 days ago") {
t.Fatalf("cloud-safe operational context must carry discovery freshness, got:\n%s", out)
}
// Freshness is non-identifying; PII still must not appear.
if strings.Contains(out, "delly-ha-host") || strings.Contains(out, "192.168.0.101") {
t.Fatalf("freshness must not bring PII into the cloud-safe context, got:\n%s", out)
}
}
func TestCloudContextPolicy_SharesOnCloudRoutingOnly(t *testing.T) {
if !(CloudContextPolicy{CloudRouting: true}).sharesCloudOperationalContext() {
t.Fatal("cloud routing must share PII-free operational context")
}
if (CloudContextPolicy{CloudRouting: false}).sharesCloudOperationalContext() {
t.Fatal("local routing must not trigger the cloud-safe path")
}
}
// 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 local-only (Restricted) guest whose hostname and IP the floor must redact
// for cloud routing even though Pulse otherwise shares real identifiers.
resource := unifiedresources.Resource{
ID: "system-container:node1:101",
Type: unifiedresources.ResourceTypeSystemContainer,
Name: "homeassistant",
Tags: []string{"secret"},
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)
}
}