diff --git a/core/proofemit/proofemit.go b/core/proofemit/proofemit.go index bbd6e5c..b836a82 100644 --- a/core/proofemit/proofemit.go +++ b/core/proofemit/proofemit.go @@ -96,6 +96,7 @@ func EmitScan(statePath string, now time.Time, findings []model.Finding, report canonicalKey := metadataString(mapped.Metadata, "canonical_finding_key") if canonicalKey != "" { findingRecordIDs[canonicalKey] = strings.TrimSpace(record.RecordID) + findingRecordIDs[canonicalFindingLookupKey(canonicalKey)] = strings.TrimSpace(record.RecordID) } summary.Findings++ summary.Total++ @@ -221,12 +222,14 @@ func linkMappedRecordToFindings(mapped *proofmap.MappedRecord, findingRecordIDs } related := []string{} if canonical := metadataString(mapped.Metadata, "canonical_finding"); canonical != "" { - if recordID := strings.TrimSpace(findingRecordIDs[canonical]); recordID != "" { + lookupKey := canonicalFindingLookupKey(canonical) + if recordID := strings.TrimSpace(findingRecordIDs[lookupKey]); recordID != "" { related = append(related, recordID) } } for _, key := range metadataStringSlice(mapped.Metadata, "attack_path_source") { - if recordID := strings.TrimSpace(findingRecordIDs[key]); recordID != "" { + lookupKey := canonicalFindingLookupKey(key) + if recordID := strings.TrimSpace(findingRecordIDs[lookupKey]); recordID != "" { related = append(related, recordID) } } @@ -316,3 +319,38 @@ func uniqueSortedStrings(values []string) []string { } return out } + +func canonicalFindingLookupKey(key string) string { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "skill_policy_conflict:") { + parts := strings.SplitN(trimmed, ":", 3) + if len(parts) != 3 { + return trimmed + } + org := normalizeCanonicalOrgPart(parts[1]) + repo := strings.TrimSpace(parts[2]) + return strings.Join([]string{"skill_policy_conflict", org, repo}, ":") + } + parts := strings.Split(trimmed, "|") + if len(parts) != 6 { + return trimmed + } + parts[0] = strings.TrimSpace(parts[0]) + parts[1] = strings.TrimSpace(parts[1]) + parts[2] = strings.TrimSpace(parts[2]) + parts[3] = strings.TrimSpace(parts[3]) + parts[4] = strings.TrimSpace(parts[4]) + parts[5] = normalizeCanonicalOrgPart(parts[5]) + return strings.Join(parts, "|") +} + +func normalizeCanonicalOrgPart(org string) string { + trimmed := strings.TrimSpace(org) + if trimmed == "" { + return "local" + } + return trimmed +} diff --git a/core/proofemit/proofemit_test.go b/core/proofemit/proofemit_test.go index 9881f74..4e8957d 100644 --- a/core/proofemit/proofemit_test.go +++ b/core/proofemit/proofemit_test.go @@ -109,3 +109,48 @@ func TestEmitIdentityTransitionAddsApprovalRecord(t *testing.T) { t.Fatalf("expected transition relationship envelope, got %#v", record.Relationship) } } + +func TestEmitScanLinksRiskRecordWhenOrgIsEmpty(t *testing.T) { + t.Parallel() + now := time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC) + statePath := filepath.Join(t.TempDir(), "state.json") + findings := []model.Finding{ + { + FindingType: "skill_policy_conflict", + Severity: model.SeverityHigh, + ToolType: "skill", + Location: ".agents/skills/deploy/SKILL.md", + Repo: "repo", + Org: " ", + }, + } + report := risk.Score(findings, 5, now) + profile := profileeval.Result{ProfileName: "standard", CompliancePercent: 90, Status: "pass"} + posture := score.Result{Score: 82.5, Grade: "B", Weights: scoremodel.DefaultWeights()} + + summary, err := EmitScan(statePath, now, findings, report, profile, posture, nil) + if err != nil { + t.Fatalf("emit scan: %v", err) + } + chain, err := LoadChain(summary.ChainPath) + if err != nil { + t.Fatalf("load proof chain: %v", err) + } + linkedRisk := false + for _, record := range chain.Records { + if record.RecordType != "risk_assessment" { + continue + } + assessmentType, _ := record.Event["assessment_type"].(string) + if assessmentType != "finding_risk" { + continue + } + if record.Relationship != nil && len(record.Relationship.RelatedRecordIDs) > 0 { + linkedRisk = true + break + } + } + if !linkedRisk { + t.Fatalf("expected finding_risk record with related_record_ids linkage for empty-org finding; chain=%#v", chain.Records) + } +} diff --git a/product/dev_guides.md b/product/dev_guides.md index 8b689de..662321f 100644 --- a/product/dev_guides.md +++ b/product/dev_guides.md @@ -160,7 +160,7 @@ All Clyra AI Go projects (proof, gait, wrkr, axym) share a dependency graph root | Component | Version | Scope | |-----------|---------|-------| | Go | `1.25.7` | All repos — `go.mod` + `.tool-versions` + CI (`go-version-file: go.mod`) | -| `Clyra-AI/proof` | `>= v0.4.4` | All downstream SKUs (gait, wrkr, axym) — minimum import version | +| `Clyra-AI/proof` | `>= v0.4.5` | All downstream SKUs (gait, wrkr, axym) — minimum import version | | Python | `3.13` | Scripts, SDKs — `pyproject.toml` + CI | | Node | `22` (LTS) | Docs sites only |