mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add resource-context safety tests
This commit is contained in:
parent
3f8525a7c2
commit
3834bc1a78
1 changed files with 432 additions and 0 deletions
432
internal/agentcontext/resource_context_sections_test.go
Normal file
432
internal/agentcontext/resource_context_sections_test.go
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
package agentcontext
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestBuildResourceContextSectionsAppliesRedactionTrustAndFreshness(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)
|
||||
observedAt := now.Add(-2 * time.Minute)
|
||||
operatorSetAt := now.Add(-1 * time.Hour)
|
||||
dockerCheckedAt := now.Add(-3 * time.Minute)
|
||||
parentID := "agent:prod-node"
|
||||
|
||||
resource := sensitiveTestResource(now, observedAt)
|
||||
resource.ParentID = &parentID
|
||||
resource.ParentName = "prod-node"
|
||||
resource.Proxmox = &unified.ProxmoxData{
|
||||
NodeName: "prod-node",
|
||||
ClusterName: "prod-west",
|
||||
VMID: 101,
|
||||
ContainerType: "lxc",
|
||||
OSName: "Debian",
|
||||
OSVersion: "12",
|
||||
HasDocker: true,
|
||||
DockerCheckedAt: &dockerCheckedAt,
|
||||
}
|
||||
resource.Docker = &unified.DockerData{
|
||||
Image: "registry.internal/finance-db:latest",
|
||||
ContainerState: "running",
|
||||
Ports: []unified.DockerPortMeta{
|
||||
{PublicPort: 8123, PrivatePort: 8123, Protocol: "tcp"},
|
||||
},
|
||||
Networks: []unified.DockerNetworkMeta{
|
||||
{Name: "backend", IPv4: "10.99.0.2"},
|
||||
},
|
||||
Mounts: []unified.DockerMountMeta{
|
||||
{Type: "bind", Source: "/var/lib/homeassistant", Destination: "/config", RW: true},
|
||||
},
|
||||
Labels: map[string]string{"com.example.secret": "label-secret"},
|
||||
}
|
||||
resource.Storage = &unified.StorageMeta{
|
||||
Type: "zfs",
|
||||
Platform: "proxmox",
|
||||
Path: "/mnt/pve/finance-db",
|
||||
RiskSummary: "finance-db.internal uses /mnt/pve/finance-db",
|
||||
}
|
||||
resource.Capabilities = []unified.ResourceCapability{
|
||||
{
|
||||
Name: "restart_container",
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
Platform: "docker",
|
||||
Params: []unified.CapabilityParam{
|
||||
{Name: "api_token", Type: "string", IsSensitive: true, DefaultValue: "super-secret-token"},
|
||||
{Name: "password", Type: "string", IsSensitive: true, DefaultValue: "another-secret"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sections := BuildResourceContextSections(resource, nil, BuildOptions{
|
||||
GeneratedAt: now,
|
||||
OperatorState: &OperatorState{IntentionallyOffline: true, NeverAutoRemediate: true, Criticality: "high", NotePresent: true, SetAt: operatorSetAt},
|
||||
ActiveFindingCount: 2,
|
||||
PendingApprovalCount: 1,
|
||||
RecentActionCount: 3,
|
||||
})
|
||||
|
||||
identity := requireSection(t, sections, "identity")
|
||||
if identity.Source != agentContextSourceUnifiedResource || identity.TrustTier != agentContextTrustPulseAuthored {
|
||||
t.Fatalf("identity provenance = source %q trust %q", identity.Source, identity.TrustTier)
|
||||
}
|
||||
if !identity.GeneratedAt.Equal(now) {
|
||||
t.Fatalf("identity generatedAt = %v, want %v", identity.GeneratedAt, now)
|
||||
}
|
||||
if identity.ObservedAt == nil || !identity.ObservedAt.Equal(observedAt) {
|
||||
t.Fatalf("identity observedAt = %v, want %v", identity.ObservedAt, observedAt)
|
||||
}
|
||||
displayName := requireFact(t, identity, "Display name")
|
||||
if displayName.Value != resource.AISafeSummary {
|
||||
t.Fatalf("display name = %q, want AI-safe summary %q", displayName.Value, resource.AISafeSummary)
|
||||
}
|
||||
canonicalID := requireFact(t, identity, "Canonical ID")
|
||||
if canonicalID.Value != unified.ResourcePolicyRedactedLabel || !canonicalID.Redacted {
|
||||
t.Fatalf("canonical ID fact = %#v, want redacted by policy", canonicalID)
|
||||
}
|
||||
|
||||
runtime := requireSection(t, sections, "runtime")
|
||||
for _, label := range []string{"Discovery agent", "Discovery resource", "Discovery hostname", "Proxmox node", "Proxmox VMID", "Image", "Storage path"} {
|
||||
fact := requireFact(t, runtime, label)
|
||||
if fact.Value != unified.ResourcePolicyRedactedLabel || !fact.Redacted {
|
||||
t.Fatalf("%s fact = %#v, want redacted by policy", label, fact)
|
||||
}
|
||||
}
|
||||
if risk := requireFact(t, runtime, "Risk summary"); !strings.Contains(risk.Value, unified.ResourcePolicyRedactedLabel) {
|
||||
t.Fatalf("risk summary = %q, want redacted label", risk.Value)
|
||||
}
|
||||
if mounts := requireFact(t, runtime, "Mounts"); mounts.Value != "1" {
|
||||
t.Fatalf("mounts fact = %q, want count only", mounts.Value)
|
||||
}
|
||||
|
||||
safety := requireSection(t, sections, "safety")
|
||||
operatorState := requireFact(t, safety, "Operator state")
|
||||
if operatorState.Source != agentContextSourceOperatorState || operatorState.TrustTier != agentContextTrustUserTaught {
|
||||
t.Fatalf("operator state provenance = source %q trust %q", operatorState.Source, operatorState.TrustTier)
|
||||
}
|
||||
if operatorState.ObservedAt == nil || !operatorState.ObservedAt.Equal(operatorSetAt) {
|
||||
t.Fatalf("operator state observedAt = %v, want %v", operatorState.ObservedAt, operatorSetAt)
|
||||
}
|
||||
for _, want := range []string{"intentionally offline", "never auto-remediate", "criticality=high", "note present"} {
|
||||
if !strings.Contains(operatorState.Value, want) {
|
||||
t.Fatalf("operator state value = %q, want %q", operatorState.Value, want)
|
||||
}
|
||||
}
|
||||
capability := requireFact(t, safety, "Capability 1")
|
||||
if !strings.Contains(capability.Value, "2 sensitive params hidden") {
|
||||
t.Fatalf("capability fact = %q, want sensitive param count", capability.Value)
|
||||
}
|
||||
if capability.Source != agentContextSourceUnifiedResource || capability.TrustTier != agentContextTrustPulseAuthored {
|
||||
t.Fatalf("capability provenance = source %q trust %q", capability.Source, capability.TrustTier)
|
||||
}
|
||||
|
||||
policy := requireSection(t, sections, "policy")
|
||||
if len(policy.Redactions) == 0 {
|
||||
t.Fatal("policy section should expose redaction reasons")
|
||||
}
|
||||
redactionReason := policy.Redactions[0].Reason
|
||||
for _, want := range []string{"Hostname", "Platform ID", "Alias", "Path"} {
|
||||
if !strings.Contains(redactionReason, want) {
|
||||
t.Fatalf("policy redaction reason = %q, want %q", redactionReason, want)
|
||||
}
|
||||
}
|
||||
|
||||
allText := sectionsAndModelText(t, resource, sections)
|
||||
assertContainsAll(t, allText,
|
||||
"[Resource Context Pack]",
|
||||
"trust=user-taught",
|
||||
"redacted=true",
|
||||
"2 sensitive params hidden",
|
||||
"Context Pack Boundary: Resource context facts are read-only",
|
||||
"not approval or execution authority",
|
||||
"must not be expanded into raw provider commands, config files, environment variables, paths, or secret-bearing metadata",
|
||||
)
|
||||
assertOmitsAll(t, allText,
|
||||
"finance-db",
|
||||
"finance-db.internal",
|
||||
"10.10.0.5",
|
||||
"prod-west:101",
|
||||
"prod-node",
|
||||
"registry.internal",
|
||||
"/mnt/pve/finance-db",
|
||||
"/var/lib/homeassistant",
|
||||
"/config",
|
||||
"label-secret",
|
||||
"api_token",
|
||||
"super-secret-token",
|
||||
"another-secret",
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildResourceContextSectionsRedactsRecentChangesThroughStore(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 13, 0, 0, 0, time.UTC)
|
||||
observedAt := now.Add(-10 * time.Minute)
|
||||
resource := sensitiveTestResource(now, observedAt)
|
||||
resource.Storage = &unified.StorageMeta{Path: "/mnt/pve/finance-db"}
|
||||
|
||||
store, err := unified.NewSQLiteResourceStore(t.TempDir(), "default")
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteResourceStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Errorf("Close store: %v", err)
|
||||
}
|
||||
})
|
||||
if err := store.RecordChange(unified.ResourceChange{
|
||||
ID: "change-sensitive-context",
|
||||
ResourceID: unified.CanonicalResourceID(resource.ID),
|
||||
ObservedAt: now.Add(-30 * time.Second),
|
||||
Kind: unified.ChangeConfigUpdate,
|
||||
From: "finance-db.internal",
|
||||
To: "10.10.0.5",
|
||||
SourceType: unified.SourcePulseDiff,
|
||||
SourceAdapter: unified.AdapterDocker,
|
||||
Confidence: unified.ConfidenceHigh,
|
||||
Reason: "updated path /mnt/pve/finance-db",
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordChange: %v", err)
|
||||
}
|
||||
|
||||
sections := BuildResourceContextSections(resource, store, BuildOptions{GeneratedAt: now})
|
||||
recentChanges := requireSection(t, sections, "recent_changes")
|
||||
if recentChanges.Source != agentContextSourceResourceStore || recentChanges.TrustTier != agentContextTrustRuntimeObserved {
|
||||
t.Fatalf("recent changes provenance = source %q trust %q", recentChanges.Source, recentChanges.TrustTier)
|
||||
}
|
||||
change := requireFact(t, recentChanges, "Change 1")
|
||||
if change.ObservedAt == nil || !change.ObservedAt.Equal(now.Add(-30*time.Second)) {
|
||||
t.Fatalf("change observedAt = %v, want recorded observation", change.ObservedAt)
|
||||
}
|
||||
assertContainsAll(t, change.Value,
|
||||
string(unified.ChangeConfigUpdate),
|
||||
unified.ResourcePolicyRedactedLabel+" -> "+unified.ResourcePolicyRedactedLabel,
|
||||
"reason=updated path "+unified.ResourcePolicyRedactedLabel,
|
||||
"source="+string(unified.SourcePulseDiff),
|
||||
"adapter="+string(unified.AdapterDocker),
|
||||
"confidence="+string(unified.ConfidenceHigh),
|
||||
)
|
||||
|
||||
allText := sectionsAndModelText(t, resource, sections)
|
||||
assertOmitsAll(t, allText,
|
||||
"finance-db.internal",
|
||||
"10.10.0.5",
|
||||
"/mnt/pve/finance-db",
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildResourceContextSectionsBoundsDiscoveredDetails(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 14, 0, 0, 0, time.UTC)
|
||||
resource := unified.Resource{
|
||||
ID: "app-container:homeassistant",
|
||||
Type: unified.ResourceTypeAppContainer,
|
||||
Name: "homeassistant",
|
||||
Status: unified.StatusOnline,
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
Docker: &unified.DockerData{
|
||||
Image: "ghcr.io/home-assistant/home-assistant:stable",
|
||||
Networks: []unified.DockerNetworkMeta{
|
||||
{Name: "iot", IPv4: "172.20.0.5"},
|
||||
},
|
||||
Mounts: []unified.DockerMountMeta{
|
||||
{Type: "bind", Source: "/var/lib/private", Destination: "/container/private", RW: true},
|
||||
},
|
||||
Labels: map[string]string{"secret.label": "label-secret"},
|
||||
},
|
||||
Relationships: []unified.ResourceRelationship{
|
||||
relationshipForLimitTest(1, now),
|
||||
relationshipForLimitTest(2, now),
|
||||
relationshipForLimitTest(3, now),
|
||||
relationshipForLimitTest(4, now),
|
||||
relationshipForLimitTest(5, now),
|
||||
relationshipForLimitTest(6, now),
|
||||
},
|
||||
Capabilities: []unified.ResourceCapability{
|
||||
capabilityForLimitTest(1),
|
||||
capabilityForLimitTest(2),
|
||||
capabilityForLimitTest(3),
|
||||
capabilityForLimitTest(4),
|
||||
capabilityForLimitTest(5),
|
||||
capabilityForLimitTest(6),
|
||||
},
|
||||
}
|
||||
|
||||
sections := BuildResourceContextSections(resource, nil, BuildOptions{GeneratedAt: now})
|
||||
runtime := requireSection(t, sections, "runtime")
|
||||
if networks := requireFact(t, runtime, "Networks"); networks.Value != "1" {
|
||||
t.Fatalf("networks fact = %q, want count only", networks.Value)
|
||||
}
|
||||
if mounts := requireFact(t, runtime, "Mounts"); mounts.Value != "1" {
|
||||
t.Fatalf("mounts fact = %q, want count only", mounts.Value)
|
||||
}
|
||||
|
||||
topology := requireSection(t, sections, "topology")
|
||||
if count := requireFact(t, topology, "Relationship count"); count.Value != "6" {
|
||||
t.Fatalf("relationship count = %q, want 6", count.Value)
|
||||
}
|
||||
if limit := requireFact(t, topology, "Relationship limit"); limit.Value != "1 additional relationships omitted from context pack" {
|
||||
t.Fatalf("relationship limit = %q", limit.Value)
|
||||
}
|
||||
if _, ok := findFact(topology, "Relationship 6"); ok {
|
||||
t.Fatal("relationship 6 should be omitted by context-pack limit")
|
||||
}
|
||||
|
||||
safety := requireSection(t, sections, "safety")
|
||||
if count := requireFact(t, safety, "Governed capabilities"); count.Value != "6" {
|
||||
t.Fatalf("capability count = %q, want 6", count.Value)
|
||||
}
|
||||
if limit := requireFact(t, safety, "Capability limit"); limit.Value != "1 additional capabilities omitted from context pack" {
|
||||
t.Fatalf("capability limit = %q", limit.Value)
|
||||
}
|
||||
if _, ok := findFact(safety, "Capability 6"); ok {
|
||||
t.Fatal("capability 6 should be omitted by context-pack limit")
|
||||
}
|
||||
|
||||
allText := sectionsAndModelText(t, resource, sections)
|
||||
assertContainsAll(t, allText, "metadata present")
|
||||
assertOmitsAll(t, allText,
|
||||
"secretPath",
|
||||
"/root/hidden",
|
||||
"172.20.0.5",
|
||||
"/var/lib/private",
|
||||
"/container/private",
|
||||
"label-secret",
|
||||
)
|
||||
}
|
||||
|
||||
func sensitiveTestResource(now, observedAt time.Time) unified.Resource {
|
||||
return unified.Resource{
|
||||
ID: "system-container:prod-west:101",
|
||||
Type: unified.ResourceTypeSystemContainer,
|
||||
Technology: "lxc",
|
||||
Name: "finance-db",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now.Add(-5 * time.Minute),
|
||||
UpdatedAt: observedAt,
|
||||
AISafeSummary: "system container resource; status online; local-only context",
|
||||
Policy: &unified.ResourcePolicy{
|
||||
Sensitivity: unified.ResourceSensitivityRestricted,
|
||||
Routing: unified.ResourceRoutingPolicy{
|
||||
Scope: unified.ResourceRoutingScopeLocalOnly,
|
||||
Redact: []unified.ResourceRedactionHint{
|
||||
unified.ResourceRedactionHostname,
|
||||
unified.ResourceRedactionIPAddress,
|
||||
unified.ResourceRedactionPlatformID,
|
||||
unified.ResourceRedactionAlias,
|
||||
unified.ResourceRedactionPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
DiscoveryTarget: &unified.DiscoveryTarget{
|
||||
ResourceType: "system-container",
|
||||
AgentID: "prod-node",
|
||||
ResourceID: "prod-west:101",
|
||||
Hostname: "finance-db.internal",
|
||||
},
|
||||
Canonical: &unified.CanonicalIdentity{
|
||||
DisplayName: "finance-db",
|
||||
Hostname: "finance-db.internal",
|
||||
PlatformID: "prod-west:101",
|
||||
PrimaryID: "system-container:prod-west:101",
|
||||
Aliases: []string{"finance-db-primary"},
|
||||
},
|
||||
Sources: []unified.DataSource{unified.SourceProxmox, unified.SourceDocker},
|
||||
SourceStatus: map[unified.DataSource]unified.SourceStatus{
|
||||
unified.SourceProxmox: {Status: "online", LastSeen: now.Add(-30 * time.Second)},
|
||||
},
|
||||
Identity: unified.ResourceIdentity{
|
||||
Hostnames: []string{"finance-db.internal"},
|
||||
IPAddresses: []string{"10.10.0.5"},
|
||||
ClusterName: "prod-west",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func relationshipForLimitTest(index int, now time.Time) unified.ResourceRelationship {
|
||||
return unified.ResourceRelationship{
|
||||
SourceID: "app-container:homeassistant",
|
||||
TargetID: "agent:node-" + string(rune('0'+index)),
|
||||
Type: unified.RelRunsOn,
|
||||
Confidence: 0.9,
|
||||
Active: true,
|
||||
Discoverer: "docker_adapter",
|
||||
ObservedAt: now.Add(-time.Duration(index) * time.Minute),
|
||||
LastSeenAt: now.Add(-time.Duration(index) * time.Minute),
|
||||
Metadata: map[string]any{
|
||||
"secretPath": "/root/hidden",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityForLimitTest(index int) unified.ResourceCapability {
|
||||
return unified.ResourceCapability{
|
||||
Name: "capability_" + string(rune('0'+index)),
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
Platform: "docker",
|
||||
}
|
||||
}
|
||||
|
||||
func requireSection(t *testing.T, sections []Section, id string) Section {
|
||||
t.Helper()
|
||||
section, ok := findSection(sections, id)
|
||||
if !ok {
|
||||
t.Fatalf("section %q missing from %+v", id, sections)
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
func findSection(sections []Section, id string) (Section, bool) {
|
||||
for _, section := range sections {
|
||||
if section.ID == id {
|
||||
return section, true
|
||||
}
|
||||
}
|
||||
return Section{}, false
|
||||
}
|
||||
|
||||
func requireFact(t *testing.T, section Section, label string) Fact {
|
||||
t.Helper()
|
||||
fact, ok := findFact(section, label)
|
||||
if !ok {
|
||||
t.Fatalf("fact %q missing from section %+v", label, section)
|
||||
}
|
||||
return fact
|
||||
}
|
||||
|
||||
func findFact(section Section, label string) (Fact, bool) {
|
||||
for _, fact := range section.Facts {
|
||||
if fact.Label == label {
|
||||
return fact, true
|
||||
}
|
||||
}
|
||||
return Fact{}, false
|
||||
}
|
||||
|
||||
func sectionsAndModelText(t *testing.T, resource unified.Resource, sections []Section) string {
|
||||
t.Helper()
|
||||
encoded, err := json.MarshalIndent(sections, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal sections: %v", err)
|
||||
}
|
||||
return string(encoded) + "\n" + FormatSectionsForModelContext(resource, sections)
|
||||
}
|
||||
|
||||
func assertContainsAll(t *testing.T, haystack string, needles ...string) {
|
||||
t.Helper()
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(haystack, needle) {
|
||||
t.Fatalf("expected text to contain %q\ntext:\n%s", needle, haystack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertOmitsAll(t *testing.T, haystack string, needles ...string) {
|
||||
t.Helper()
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(haystack, needle) {
|
||||
t.Fatalf("expected text to omit %q\ntext:\n%s", needle, haystack)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue