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