Reject incoherent Patrol finding identities

This commit is contained in:
rcourtman
2026-08-17 13:31:16 +01:00
parent a83e15176a
commit da8537b8e6
3 changed files with 98 additions and 0 deletions
@@ -7499,6 +7499,16 @@ target. Deleted-resource reconciliation still uses the full current global
state, and the synthetic Patrol runtime finding retains its existing special
handling.
Finding creation also validates the provider-authored resource ID and display
name as one identity pair against the current runtime snapshot. When both
values resolve uniquely but identify different same-type resources, core
rejects the report and lets the bounded finding repair turn resubmit a coherent
canonical pair; it never guesses which value was intended. This prevents one
incident from being persisted twice when a report copies the ID of an in-scope
sibling while describing the correct affected resource by name. The existing
exact-name repair remains limited to unknown ID transcription errors and does
not redirect a real known resource ID across the finding scope boundary.
A Watch provider turn that stops because its output-token allowance was
exhausted is not a completed finding decision. Core preserves the partial turn
only as model context, excludes its unfinished prose from the durable Patrol
+54
View File
@@ -550,6 +550,9 @@ func newPatrolFindingCreatorAdapterState(p *PatrolService, snap patrolRuntimeSta
}
func (a *patrolFindingCreatorAdapter) CreateFinding(input tools.PatrolFindingInput) (string, bool, error) {
if err := a.validateFindingResourceIdentity(input); err != nil {
return "", false, err
}
if canonical, repaired := a.canonicalizeUnknownFindingResource(input); repaired {
log.Info().
Str("submitted_resource_id", input.ResourceID).
@@ -673,6 +676,57 @@ func (a *patrolFindingCreatorAdapter) CreateFinding(input tools.PatrolFindingInp
return id, isNew, nil
}
// validateFindingResourceIdentity rejects a model-authored resource ID/name
// pair when each value resolves uniquely to a different same-type runtime
// resource. Either value can be copied incorrectly, so core must not guess
// which one the provider intended. Returning a validation error lets the
// bounded finding repair turn submit one coherent canonical identity instead
// of persisting duplicate incidents against sibling resources.
func (a *patrolFindingCreatorAdapter) validateFindingResourceIdentity(input tools.PatrolFindingInput) error {
if a == nil || strings.TrimSpace(input.ResourceID) == "" || strings.TrimSpace(input.ResourceName) == "" {
return nil
}
resourceIDToken := canonicalPatrolScopeToken(input.ResourceID)
resourceNameToken := canonicalPatrolScopeToken(input.ResourceName)
resourceTypeToken := canonicalPatrolScopeToken(input.ResourceType)
idMatches := make([]int, 0, 1)
nameMatches := make([]int, 0, 1)
recordIndex := 0
patrolVisitRuntimeResources(a.snap, func(record patrolRuntimeResourceRecord) bool {
currentIndex := recordIndex
recordIndex++
if resourceTypeToken != "" && canonicalPatrolScopeToken(record.resourceType) != resourceTypeToken {
return true
}
for _, value := range append(append([]string(nil), record.ids...), record.aliases...) {
if canonicalPatrolScopeToken(value) == resourceIDToken {
idMatches = append(idMatches, currentIndex)
break
}
}
for _, alias := range record.aliases {
if canonicalPatrolScopeToken(alias) == resourceNameToken {
nameMatches = append(nameMatches, currentIndex)
break
}
}
return true
})
if len(idMatches) == 1 && len(nameMatches) == 1 && idMatches[0] != nameMatches[0] {
return fmt.Errorf(
"resource identity mismatch: resource_id %q and resource_name %q identify different %s resources; submit one canonical ID/name pair",
input.ResourceID,
input.ResourceName,
input.ResourceType,
)
}
return nil
}
// canonicalizeUnknownFindingResource repairs only a redundant model-authored
// ID transcription error. The submitted ID must match no current resource,
// while the exact submitted name must resolve to one same-type runtime record
@@ -1658,6 +1658,40 @@ func TestPatrolFindingAdapterRepairsOnlyUnknownIDsFromUniqueScopedIdentity(t *te
}
}
func TestPatrolFindingAdapterRejectsKnownSiblingIDAndNameMismatch(t *testing.T) {
state := newPatrolRuntimeState(models.StateSnapshot{
DockerHosts: []models.DockerHost{{
ID: "docker-host-1",
Containers: []models.DockerContainer{
{ID: "app-container-healthy", Name: "control", State: "running", Health: "healthy"},
{ID: "app-container-unhealthy", Name: "existing", State: "running", Health: "unhealthy"},
},
}},
})
ps := NewPatrolService(nil, nil)
adapter := newPatrolFindingCreatorAdapterState(ps, state)
adapter.setFindingScope([]string{"app-container-healthy", "app-container-unhealthy"})
input := tools.PatrolFindingInput{
ResourceID: "app-container-healthy", ResourceName: "existing", ResourceType: "app-container",
Key: "health-check-failed", Severity: "warning", Category: "reliability",
Title: "Container health check is unhealthy", Description: "The existing container is unhealthy.",
Impact: "Requests may fail.", Recommendation: "Inspect the health check.",
Evidence: "Current provider health for existing is unhealthy.",
}
if _, isNew, err := adapter.CreateFinding(input); err == nil || isNew || !strings.Contains(err.Error(), "resource identity mismatch") {
t.Fatalf("mismatched sibling report = (%t, %v), want identity validation error", isNew, err)
}
if active := ps.findings.GetActive(FindingSeverityInfo); len(active) != 0 {
t.Fatalf("mismatched sibling report persisted findings: %+v", active)
}
input.ResourceID = "app-container-unhealthy"
if findingID, isNew, err := adapter.CreateFinding(input); err != nil || !isNew || findingID == "" {
t.Fatalf("coherent canonical report = (%q, %t, %v), want new finding", findingID, isNew, err)
}
}
func TestPatrolFindingAdapterCanAssessSeededRuntimeFindingDuringScopedRun(t *testing.T) {
state := newPatrolRuntimeState(models.StateSnapshot{
VMs: []models.VM{{ID: "vm-requested", Name: "requested", VMID: 501}},