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:
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user