feat(decnet_web/AttackerDetail): Behavioural primitives panel

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.
This commit is contained in:
2026-05-08 20:26:55 -04:00
parent 2ff2537f6c
commit 7634e31e5a

View File

@@ -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<string> = [
'motor.input_modality',
'cognitive.feedback_loop_engagement',
'cognitive.command_branch_diversity',
'cognitive.inter_command_latency_class',
];
const BEHAVIOUR_DOMAIN_ORDER: ReadonlyArray<string> = [
'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<BehaviouralObservation>;
}> = ({ observations }) => {
if (!observations.length) {
return (
<div className="info-banner" data-testid="behaviour-empty">
<span className="dim">No behavioural observations yet the profiler runs once a session ends.</span>
</div>
);
}
// Group by top-level domain, sort each group by the priority-then-alpha
// comparator, then walk the canonical domain order.
const groups = new Map<string, BehaviouralObservation[]>();
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 (
<div className="behaviour-panel" data-testid="behaviour-panel">
{orderedDomains.map((domain) => (
<div key={domain} className="behaviour-group" data-testid={`behaviour-group-${domain}`}>
<div className="page-header dim">{domain.toUpperCase()}</div>
{groups.get(domain)!.map((obs) => (
<div
key={obs.primitive}
className="behaviour-row"
data-testid={`behaviour-row-${obs.primitive}`}
>
<span className="behaviour-leaf">{_leafOf(obs.primitive)}</span>
<span className="behaviour-value matrix-text">{_renderValue(obs.value)}</span>
<span className="behaviour-confidence dim">
{(obs.confidence * 100).toFixed(0)}%
</span>
</div>
))}
</div>
))}
</div>
);
};
// ─── 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<AttackerData | null>(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<BehaviouralObservation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(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 = () => {
)}
</Section>
{/* Behavioural primitives (BEHAVE-SHELL) */}
<Section
title="BEHAVIOURAL PRIMITIVES"
open={openSections.behavioural}
onToggle={() => toggle('behavioural')}
>
<BehaviouralPrimitivesPanel observations={observations} />
</Section>
{/* Commands */}
{(() => {
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);