From 7634e31e5a97503c69bbbcb182dd55c1924676c5 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 8 May 2026 20:26:55 -0400 Subject: [PATCH] feat(decnet_web/AttackerDetail): Behavioural primitives panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the AttackerDetail.tsx panel that surfaces BEHAVE-SHELL behavioural primitives. Hydrates from the existing GET /api/v1/attackers/{uuid} response field 'observations', live-updates via the new useAttackerStream hook (replace-by-primitive on every 'observation' SSE event). * New BehaviouralPrimitivesPanel component, exported for vitest. * Day-one render priority per BEHAVE-INTEGRATION.md §441-454: motor.input_modality, cognitive.feedback_loop_engagement, cognitive.command_branch_diversity, cognitive.inter_command_latency_class — these four sort to the top of their respective groups; everything else alphabetises. * Grouped by top-level domain (motor / cognitive / temporal / operational / environmental / emotional_valence) with the canonical domain order; unknown domains alphabetise at the end. * AttackerData interface gains an 'observations' field. * Empty-state placeholder when the panel has nothing yet. * Section collapse state extends to 'behavioural', defaults open. tsc --noEmit clean. Vitest coverage ships in P5.4. --- decnet_web/src/components/AttackerDetail.tsx | 146 +++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index c541fa42..d2f03a18 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -8,6 +8,7 @@ import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; import TTPsObservedSection from './TTPsObservedSection'; import { useIdentityStream } from './useIdentityStream'; +import { useAttackerStream, type ObservationFrame } from './useAttackerStream'; import './Dashboard.css'; interface AttackerBehavior { @@ -96,6 +97,20 @@ interface AttackerData { }; }>; ip_leaks_total?: number; + // BEHAVE-SHELL behavioural primitives — latest value per primitive + // for this attacker. The REST `/api/v1/attackers/{uuid}` route + // returns this field; the SSE `/events` stream live-updates it via + // useAttackerStream. Empty array until the profiler worker has + // processed at least one session shard for this attacker. + observations?: BehaviouralObservation[]; +} + +export interface BehaviouralObservation { + primitive: string; + value: unknown; + confidence: number; + ts?: number; + source?: string; } // ─── Fingerprint rendering ─────────────────────────────────────────────────── @@ -880,6 +895,97 @@ const PhaseSequenceBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { ); }; +// ─── Behavioural primitives panel (BEHAVE-INTEGRATION Phase 5) ───────────── + +// Day-one render priority per BEHAVE-INTEGRATION.md §441-454. These four +// primitives carry the highest discriminative value for the "is this the +// same operator class" hover story; everything else alphabetises. +const BEHAVIOUR_PRIORITY: ReadonlyArray = [ + 'motor.input_modality', + 'cognitive.feedback_loop_engagement', + 'cognitive.command_branch_diversity', + 'cognitive.inter_command_latency_class', +]; + +const BEHAVIOUR_DOMAIN_ORDER: ReadonlyArray = [ + 'motor', 'cognitive', 'temporal', 'operational', + 'environmental', 'emotional_valence', +]; + +function _domainOf(primitive: string): string { + return primitive.split('.', 1)[0]; +} + +function _leafOf(primitive: string): string { + return primitive.split('.').slice(1).join('.'); +} + +function _comparePrimitives(a: string, b: string): number { + const ai = BEHAVIOUR_PRIORITY.indexOf(a); + const bi = BEHAVIOUR_PRIORITY.indexOf(b); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return a.localeCompare(b); +} + +function _renderValue(value: unknown): string { + if (value === null || value === undefined) return '—'; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} + +export const BehaviouralPrimitivesPanel: React.FC<{ + observations: ReadonlyArray; +}> = ({ observations }) => { + if (!observations.length) { + return ( +
+ No behavioural observations yet — the profiler runs once a session ends. +
+ ); + } + // Group by top-level domain, sort each group by the priority-then-alpha + // comparator, then walk the canonical domain order. + const groups = new Map(); + for (const obs of observations) { + const domain = _domainOf(obs.primitive); + const list = groups.get(domain) ?? []; + list.push(obs); + groups.set(domain, list); + } + for (const list of groups.values()) { + list.sort((a, b) => _comparePrimitives(a.primitive, b.primitive)); + } + const orderedDomains = [ + ...BEHAVIOUR_DOMAIN_ORDER.filter((d) => groups.has(d)), + ...Array.from(groups.keys()).filter((d) => !BEHAVIOUR_DOMAIN_ORDER.includes(d)).sort(), + ]; + return ( +
+ {orderedDomains.map((domain) => ( +
+
{domain.toUpperCase()}
+ {groups.get(domain)!.map((obs) => ( +
+ {_leafOf(obs.primitive)} + {_renderValue(obs.value)} + + {(obs.confidence * 100).toFixed(0)}% + +
+ ))} +
+ ))} +
+ ); +}; + // ─── Collapsible section ──────────────────────────────────────────────────── const Section: React.FC<{ @@ -1253,6 +1359,10 @@ const AttackerDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [attacker, setAttacker] = useState(null); + // Live behavioural-primitive state. Seeded from + // attacker.observations on first fetch; mutated in place by the + // useAttackerStream hook below (latest-wins per primitive). + const [observations, setObservations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [serviceFilter, setServiceFilter] = useState(null); @@ -1263,6 +1373,7 @@ const AttackerDetail: React.FC = () => { services: true, deckies: true, behavior: true, + behavioural: true, commands: true, fingerprints: true, intel: true, @@ -1329,6 +1440,7 @@ const AttackerDetail: React.FC = () => { try { const res = await api.get(`/attackers/${id}`); setAttacker(res.data); + setObservations(res.data?.observations ?? []); } catch (err: any) { if (err.response?.status === 404) { setError('ATTACKER NOT FOUND'); @@ -1375,6 +1487,31 @@ const AttackerDetail: React.FC = () => { }, }); + // Live behavioural-primitive updates: subscribe to per-attacker + // SSE and replace-by-primitive on every observation event. + useAttackerStream({ + attackerUuid: id ?? '', + enabled: !!id, + onSnapshot: (data) => { + setObservations(data.observations ?? []); + }, + onObservation: (frame: ObservationFrame) => { + setObservations((prev) => { + const filtered = prev.filter((o) => o.primitive !== frame.primitive); + return [ + ...filtered, + { + primitive: frame.primitive, + value: frame.value, + confidence: frame.confidence, + ts: frame.ts, + source: frame.source, + }, + ]; + }); + }, + }); + useEffect(() => { if (!id) return; const fetchCommands = async () => { @@ -1755,6 +1892,15 @@ const AttackerDetail: React.FC = () => { )} + {/* Behavioural primitives (BEHAVE-SHELL) */} +
toggle('behavioural')} + > + +
+ {/* Commands */} {(() => { const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);