package bundle // signals.go computes the cross-file trust SIGNALS for a bundle independently of // the heuristic engine. BundleSignals is consumed by BOTH the heuristic bridge // (pkg/heuristic.BundleCategoryScores) and the next-retrain feature export // (cmd/train + pkg/types.BundleFeatureVector), so the two stay in sync: the // boolean/numeric facts live here once, and each consumer projects them. // BundleSignals holds the named cross-file signals derived from a bundle's files // and its analyzer findings. The boolean signals correspond 1:1 to the // cross-file signal catalog; the three numeric signals quantify aggregate risk. // CorroboratedHighRisk is the precision gate driving bundleEscalates and the // scorer name-allowlist veto: it is true only when a SevHigh+ finding was // behaviorally corroborated (or non-structural), never on a bare structural // signal alone. type BundleSignals struct { DelegatesToBundledScript bool `json:"delegates_to_bundled_script"` ShipsOpaqueExecutable bool `json:"ships_opaque_executable"` CompiledWithoutMatchingSource bool `json:"compiled_without_matching_source"` CompiledSourceMismatch bool `json:"compiled_source_mismatch"` ArchiveContainsExecutable bool `json:"archive_contains_executable"` ReferencesUnscannedFiletype bool `json:"references_unscanned_filetype"` DelegatesToData bool `json:"delegates_to_data"` DelegatesToImage bool `json:"delegates_to_image"` NLDirectiveActiveTools bool `json:"nl_directive_active_tools"` SymlinkedExecutableReference bool `json:"symlinked_executable_reference"` PaddingEvasion bool `json:"padding_evasion"` HiddenPayloadFiles int `json:"hidden_payload_files"` PayloadToMarkdownRatio float64 `json:"payload_to_markdown_ratio"` MaxSiblingAnalyzerRisk float64 `json:"max_sibling_analyzer_risk"` CorroboratedHighRisk bool `json:"corroborated_high_risk"` } // signalAliases maps each boolean BundleSignals field to the stable Finding // Signal name(s) that set it. Multiple aliases are accepted per field so the // aggregation stays correct whether an analyzer emits the spec's canonical name // or a close synonym. The defanged exfil names are intentionally NOT here — they // drive Severity, which feeds CorroboratedHighRisk / MaxSiblingAnalyzerRisk. var signalAliases = map[string][]string{ "DelegatesToBundledScript": {"delegates-to-bundled-script"}, "ShipsOpaqueExecutable": {"ships-opaque-executable"}, "CompiledWithoutMatchingSource": {"compiled-without-matching-source", "ships-compiled-bytecode"}, "CompiledSourceMismatch": {"compiled-source-mismatch"}, "ArchiveContainsExecutable": {"archive-contains-executable"}, "ReferencesUnscannedFiletype": {"references-unscanned-filetype"}, "DelegatesToData": {"delegates-to-data"}, "DelegatesToImage": {"delegates-to-image"}, "NLDirectiveActiveTools": {"allowed-tools-nl-directive", "nl-directive-active-tools", "nl-directive"}, "SymlinkedExecutableReference": {"symlinked-executable-reference"}, "PaddingEvasion": {"padding-evasion"}, } // AggregateSignals folds a bundle's files and analyzer findings into the named // cross-file signals. It is pure: same inputs produce the same BundleSignals. func AggregateSignals(b *Bundle, findings []Finding) BundleSignals { var s BundleSignals present := make(map[string]bool, len(findings)) for _, f := range findings { present[f.Signal] = true if w := severityToWeight(f.Severity); w > s.MaxSiblingAnalyzerRisk { s.MaxSiblingAnalyzerRisk = w } // Precision gate: a SevHigh+ finding contributes to high-risk only when // it carries a behavioral co-factor (Corroborated) or is non-structural. if severityToWeight(f.Severity) >= severityToWeight(SevHigh) && (f.Corroborated || !f.Structural) { s.CorroboratedHighRisk = true } } s.DelegatesToBundledScript = anyPresent(present, signalAliases["DelegatesToBundledScript"]) s.ShipsOpaqueExecutable = anyPresent(present, signalAliases["ShipsOpaqueExecutable"]) s.CompiledWithoutMatchingSource = anyPresent(present, signalAliases["CompiledWithoutMatchingSource"]) s.CompiledSourceMismatch = anyPresent(present, signalAliases["CompiledSourceMismatch"]) s.ArchiveContainsExecutable = anyPresent(present, signalAliases["ArchiveContainsExecutable"]) s.ReferencesUnscannedFiletype = anyPresent(present, signalAliases["ReferencesUnscannedFiletype"]) s.DelegatesToData = anyPresent(present, signalAliases["DelegatesToData"]) s.DelegatesToImage = anyPresent(present, signalAliases["DelegatesToImage"]) s.NLDirectiveActiveTools = anyPresent(present, signalAliases["NLDirectiveActiveTools"]) s.SymlinkedExecutableReference = anyPresent(present, signalAliases["SymlinkedExecutableReference"]) s.PaddingEvasion = anyPresent(present, signalAliases["PaddingEvasion"]) s.HiddenPayloadFiles = countHiddenPayloadFiles(b, findings) s.PayloadToMarkdownRatio = payloadToMarkdownRatio(b) return s } // anyPresent reports whether any of the given signal names appears in the set. func anyPresent(present map[string]bool, names []string) bool { for _, n := range names { if present[n] { return true } } return false } // countHiddenPayloadFiles counts hidden (dot-prefixed) non-markdown sibling files // that carry at least one analyzer finding. Scanners that skip hidden files miss // exactly these, so a hidden file with a finding is a distinct evasion signal. func countHiddenPayloadFiles(b *Bundle, findings []Finding) int { if b == nil { return 0 } withFinding := make(map[string]bool, len(findings)) for _, f := range findings { if f.File != "" { withFinding[f.File] = true } } n := 0 for _, f := range b.Files { if f == nil || !f.Hidden { continue } if f.Kind == KindMarkdown || f.Kind == KindSkillMd { continue } if withFinding[f.RelPath] { n++ } } return n } // payloadToMarkdownRatio is PayloadBytes / max(SkillMdBytes, 1): a tiny benign // SKILL.md beside large opaque payloads is suspicious. func payloadToMarkdownRatio(b *Bundle) float64 { if b == nil { return 0 } md := b.SkillMdBytes if md < 1 { md = 1 } return float64(b.PayloadBytes) / float64(md) } // Names returns the stable signal names in the same order as ToSlice, so the // trainer can emit deterministic columns aligned with pkg/types.BundleFeatureNames. func (s BundleSignals) Names() []string { return []string{ "delegates_to_bundled_script", "ships_opaque_executable", "compiled_without_matching_source", "compiled_source_mismatch", "archive_contains_executable", "references_unscanned_filetype", "delegates_to_data", "delegates_to_image", "nl_directive_active_tools", "symlinked_executable_reference", "padding_evasion", "hidden_payload_files", "payload_to_markdown_ratio", "max_sibling_analyzer_risk", "corroborated_high_risk", } } // ToSlice flattens the signals into 15 floats (bools -> 0/1) in Names() order. // This vector is reserved for the NEXT retrain ONLY and must NEVER be wired into // the frozen FeatureVector.ToSlice (143 floats) bound to the current ONNX model. func (s BundleSignals) ToSlice() []float64 { return []float64{ b2f(s.DelegatesToBundledScript), b2f(s.ShipsOpaqueExecutable), b2f(s.CompiledWithoutMatchingSource), b2f(s.CompiledSourceMismatch), b2f(s.ArchiveContainsExecutable), b2f(s.ReferencesUnscannedFiletype), b2f(s.DelegatesToData), b2f(s.DelegatesToImage), b2f(s.NLDirectiveActiveTools), b2f(s.SymlinkedExecutableReference), b2f(s.PaddingEvasion), float64(s.HiddenPayloadFiles), s.PayloadToMarkdownRatio, s.MaxSiblingAnalyzerRisk, b2f(s.CorroboratedHighRisk), } } func b2f(v bool) float64 { if v { return 1 } return 0 }