Three sibling design docs plus DEBT.md updates that supersede the stale DEBT-036 with a BEHAVE-aligned plan. development/BEHAVE-INTEGRATION.md — five-phase rollout: storage (observations table mirroring the BEHAVE Observation envelope plus one DECNET-side denorm; UniqueConstraint(evidence_ref, primitive) enforcing idempotency); engine (in decnet/profiler/behave_shell/ sublibrary, no new daemon, not in BEHAVE — DECNET is the engine); BEHAVE pin; worker wire; UI panel + per-attacker SSE route; live smoke. Bus payload merges id/ts/v back in to preserve sensor identifiers across the bus envelope. development/BEHAVE-EXTRACTOR.md — engine route in eight phases (A–H). Phase A locks the 6-primitive calibration grid; Phases B–G expand horizontally; Phase H is the full Tier-A corpus + v0 release. v0 ships every shell-extractable primitive (37 of them); Tier B is cross-session and lives in the attribution engine; Tier C is network-domain (toolchain.*) and lives elsewhere. development/ATTRIBUTION-ENGINE.md — sublibrary inside decnet/correlation/ that consumes attacker.observation.* events and emits attribution.profile.* derived state. Five-state machine (unknown / stable / drifting / conflicted / multi_actor) with per- ValueKind merge functions. v0 closes DEBT-051; v1 adds the real clusterer; v2 federation gossip. The bright line forbidding attribution to natural persons is lifted directly from BEHAVE's envelope docstring. development/DEBT.md — DEBT-036 marked STALE; DEBT-050 and DEBT-051 entries added; summary table + open list updated.
681 lines
31 KiB
Markdown
681 lines
31 KiB
Markdown
# BEHAVE Integration — Design
|
|
|
|
**Status:** pre-implementation. This doc is the spec; code follows.
|
|
**Tracks:** DEBT-050 (replaces stale DEBT-036).
|
|
**Spec source:** `/home/anti/Tools/BEHAVE` (sibling, never vendored).
|
|
**Engine home:** this repo, `decnet/profiler/behave_shell/` (sublibrary inside the existing `profiler` worker — no new daemon).
|
|
|
|
## Premise
|
|
|
|
ANTI built BEHAVE — an out-of-tree behavioural-observation framework
|
|
with a primitive registry, a registry-validated `Observation`
|
|
envelope, a DECNET-bus event adapter, and a five-class calibration
|
|
grid (HUMAN / YOU-sim / LW-sim / CLAUDE-FF / CLAUDE-CL). It is the
|
|
right substrate for keystroke-dynamics extraction.
|
|
|
|
The original DEBT-036 plan (hand-rolled `kd_*` columns on
|
|
`SessionProfile`) is obsolete. This doc replaces it with a
|
|
BEHAVE-aligned ingester that emits registry-validated observations on
|
|
the bus and persists them in a single generic table.
|
|
|
|
**Bright line, lifted from BEHAVE itself:** *BEHAVE emits
|
|
observations. It does not conclude.* DECNET is a consumer of
|
|
`attacker.observation.*` events; attribution / linkage / verdicts are
|
|
out-of-scope for this integration and live in their own (future)
|
|
attribution engine.
|
|
|
|
## Architectural placement
|
|
|
|
```
|
|
/home/anti/Tools/
|
|
├── BEHAVE/ sibling repo, separate git history
|
|
│ ├── core/ decnet-behave-core (envelope)
|
|
│ ├── BEHAVE-SHELL/ decnet-behave-shell (registry + adapter)
|
|
│ └── prototype_extractors/shell/ extract.py — JSONL → Observation stream
|
|
│
|
|
└── DECNET/ THIS repo
|
|
├── pyproject.toml pins decnet-behave-{core,shell}
|
|
├── decnet/profiler/ EXISTING worker — gains a sublibrary + a new trigger
|
|
│ ├── worker.py gains attacker.session.ended subscription
|
|
│ ├── behavioral.py UNCHANGED — networking-domain (LogEvent IATs, beacon detection)
|
|
│ ├── timing.py UNCHANGED — networking-domain
|
|
│ └── behave_shell/ NEW — pure extraction library
|
|
│ ├── __init__.py
|
|
│ ├── extract.py orchestration: parse → dispatch → assemble Observations
|
|
│ └── _features/ per-primitive-family modules
|
|
└── decnet/web/db/models/observations.py NEW — generic Observation table
|
|
```
|
|
|
|
**No new worker.** The existing `decnet-profiler.service` already
|
|
supervises this codepath. No new systemd unit, no new polkit rule, no
|
|
new heartbeat. The session-ended handler is a peer to the existing
|
|
scoring tick inside the same async loop.
|
|
|
|
**Audit finding (network vs PTY domains).** `behavioral.py` and
|
|
`timing.py` operate on `LogEvent` (network-level connection events
|
|
from `decnet.correlation.parser`), feeding the existing
|
|
`attacker_behavior` table — TCP fingerprint, OS guess, beacon
|
|
interval, behavior class. **Zero overlap with BEHAVE-SHELL**, which
|
|
operates on `AsciinemaEvent` (PTY input) and persists to the new
|
|
`observations` table. The two coexist; no rewrite, no migration, no
|
|
shared state.
|
|
|
|
Two repos, two commits, no vendoring. `pip install -e
|
|
../BEHAVE/core ../BEHAVE/BEHAVE-SHELL` for local dev; pinned wheels in
|
|
CI.
|
|
|
|
## BEHAVE is the spec. DECNET is the engine.
|
|
|
|
This is a *load-bearing* architectural fact, called out explicitly so
|
|
nobody (including future me) misreads the layout.
|
|
|
|
- **BEHAVE ships:** the primitive registry, the registry-validated
|
|
`Observation` envelope, the bus event adapter, the JSON schema.
|
|
Reference prototype extractor for spec validation only. BEHAVE will
|
|
**not** ship a production engine — that's not what the BEHAVE repo
|
|
is for.
|
|
- **DECNET ships:** the production extraction engine. It lives in
|
|
`decnet/profiler/behave_shell/`, written from scratch against the
|
|
BEHAVE spec, called from the existing profiler worker on
|
|
`attacker.session.ended`.
|
|
|
|
DECNET-side BEHAVE imports are spec-only:
|
|
|
|
```python
|
|
from decnet_behave_core.spec.envelope import Observation as ObservationEnvelope, Window
|
|
from decnet_behave_shell.spec.primitives import PRIMITIVE_REGISTRY, get as get_primitive_spec
|
|
from decnet_behave_shell.spec.event_adapter import event_topic_for, to_event_payload
|
|
```
|
|
|
|
`Observation` is aliased to `ObservationEnvelope` so the storage
|
|
SQLModel can keep the `Observation`-flavoured class name where it's
|
|
useful, and the BEHAVE primitive-spec accessor is aliased away from
|
|
the bare name `get` to avoid shadowing in feature-extractor modules
|
|
that read dicts heavily.
|
|
|
|
That's it. No imports from `BEHAVE/prototype_extractors/`. The
|
|
prototype is read as **design notes** during the engine build, then
|
|
ignored. If the prototype yields a primitive the production engine
|
|
doesn't, that's a calibration delta to investigate, not a regression
|
|
in either direction.
|
|
|
|
### The extraction engine — DECNET-side
|
|
|
|
```
|
|
decnet/profiler/behave_shell/
|
|
├── __init__.py exposes extract_session()
|
|
├── extract.py orchestration: parse → dispatch → assemble Observations
|
|
└── _features/ feature-extractor modules, one per primitive family
|
|
├── motor.py cadence, paste burst, modality, shell mastery
|
|
├── cognitive.py latency class, consistency, branch diversity, feedback loop
|
|
├── temporal.py session timing, escalation pattern
|
|
└── ... others added as primitives are productionised
|
|
|
|
tests/profiler/behave_shell/
|
|
└── _features/ one test module per feature family, against synthetic streams
|
|
```
|
|
|
|
The library is **pure** — no I/O, no bus calls, no DB writes. Events
|
|
in → `Iterable[Observation]` out. The split between `extract.py`
|
|
(orchestration) and `_features/` (per-family implementations) keeps
|
|
each primitive's logic auditable in isolation — including the
|
|
threshold tables, which are the part most likely to drift across
|
|
calibration cycles. The worker (in `decnet/profiler/worker.py`) owns
|
|
all I/O: disk-reach, bus publish, DB upsert.
|
|
|
|
**The engine is its own first-class effort, not a side-effect of
|
|
this integration doc.** The five-class calibration grid is the
|
|
acceptance test. Beyond that, it has its own design surface
|
|
(threshold calibration methodology, per-primitive confidence scoring,
|
|
feature-family precedence rules) that this doc does not attempt to
|
|
fully specify — that belongs in a sibling `BEHAVE-EXTRACTOR.md` once
|
|
Phase 1 lands and we have the storage shape to write into.
|
|
|
|
**Calibration knowledge does leak across the repo boundary.** BEHAVE's
|
|
`primitives.py` carries empirical calibration notes (e.g. CLAUDE-FF
|
|
vs CLAUDE-CL on 2026-05-02) inline in the registry. The clean
|
|
separation "BEHAVE = pure spec, DECNET = pure engine" is leakier
|
|
than this doc would prefer; both repos must agree on what a primitive
|
|
*means* before the engine threshold tables are tuned. Treat the
|
|
registry's `notes:` field as ground truth and tune DECNET to match.
|
|
|
|
### BEHAVE-side commits (rare, for spec changes only)
|
|
|
|
The only reasons to touch the BEHAVE repo during this integration:
|
|
|
|
1. The DECNET engine discovers a primitive the registry needs and the
|
|
spec doesn't yet define → registry edit in BEHAVE → version bump
|
|
→ DECNET pin update.
|
|
2. The envelope schema needs a field DECNET can populate honestly
|
|
(e.g. a structured `evidence_ref` schema) → envelope edit → schema
|
|
`v` bump → `observations.envelope_v` column already tracks it.
|
|
|
|
These are not blockers for Phase 1. They land iteratively as the
|
|
engine matures.
|
|
|
|
## Versioning
|
|
|
|
| Axis | Current | DECNET pin |
|
|
|---|---|---|
|
|
| Envelope schema (`Observation.v`) | `1` | column `observations.envelope_v` tracks it |
|
|
| Schema URL | `https://behave.local/schema/observation/v1.json` | — |
|
|
| `decnet-behave-core` | `0.1.0` | `>=0.1.0,<0.2` |
|
|
| `decnet-behave-shell` | `0.1.0` | `>=0.1.0,<0.2` |
|
|
|
|
A future `v=2` envelope coexists in the same table without a
|
|
destructive migration — query by `envelope_v` when shape diverges.
|
|
Bump the cap in `pyproject.toml` when BEHAVE cuts `0.2.0`.
|
|
|
|
## Data flow
|
|
|
|
```
|
|
asciinema shard on disk
|
|
/var/lib/decnet/artifacts/{decky}/sessrec/sessions-YYYY-MM-DD.jsonl
|
|
│
|
|
│ disk-reach (host-local, never on bus)
|
|
▼
|
|
bus: attacker.session.ended ─► decnet-profiler worker (existing)
|
|
(or poll fallback) │ → handler in worker.py
|
|
│ → calls behave_shell.extract_session(events) → Iterable[Observation]
|
|
│ (registry-validated by BEHAVE)
|
|
▼
|
|
bus.publish(event_topic_for(obs.primitive),
|
|
to_event_payload(obs))
|
|
│
|
|
┌─────────────────────┼──────────────────────┐
|
|
▼ ▼ ▼
|
|
observations table AttackerDetail UI future: attribution engine,
|
|
(DECNET storage) (live SSE consumer) federation gossip, webhook export
|
|
```
|
|
|
|
Raw `[t,"i",d]` events never cross the worker→bus boundary. Bus
|
|
carries observation envelopes only. Disk-reach for the input stream
|
|
mirrors DEBT-047's pattern (filesystem-group-readable artifacts via
|
|
DEBT-035).
|
|
|
|
## Storage — the `observations` table
|
|
|
|
Generic table holding every BEHAVE envelope field, plus a single
|
|
DECNET-side denormalization (`attacker_uuid`) for cheap joins.
|
|
**Not a strict 1:1 mirror** — the envelope has no `attacker_uuid`;
|
|
DECNET adds it so AttackerDetail doesn't have to chase
|
|
`identity_ref → AttackerIdentity → attacker_uuid` on every read.
|
|
|
|
The SQLModel class is named `ObservationRow` to avoid colliding
|
|
with the BEHAVE `Observation` Pydantic class imported into the
|
|
same module.
|
|
|
|
```python
|
|
# decnet/web/db/models/observations.py
|
|
from decnet_behave_core.spec.envelope import Observation as ObservationEnvelope
|
|
|
|
class ObservationRow(SQLModel, table=True):
|
|
__tablename__ = "observations"
|
|
|
|
# ── envelope fields (types match BEHAVE exactly) ─────────────
|
|
id: str = Field(primary_key=True) # envelope.id (uuid4().hex string)
|
|
identity_ref: str | None = None # envelope.identity_ref (str, not UUID)
|
|
primitive: str = Field(index=True) # 'motor.keystroke_cadence'
|
|
value: dict[str, Any] | str | int | float | bool | list = \
|
|
Field(sa_column=Column(JSON, nullable=False))
|
|
confidence: float
|
|
window_start_ts: float # flattened from envelope.window
|
|
window_end_ts: float
|
|
source: str
|
|
evidence_ref: str = Field(nullable=False) # NOT NULL for DECNET emissions; see "Idempotency"
|
|
envelope_v: int # envelope.v
|
|
ts: float = Field(index=True) # emission ts
|
|
|
|
# ── DECNET-side denormalization (NOT in BEHAVE envelope) ─────
|
|
attacker_uuid: UUID = Field(foreign_key="attackers.uuid", index=True)
|
|
|
|
__table_args__ = (
|
|
Index("ix_observations_attacker_primitive_ts",
|
|
"attacker_uuid", "primitive", "ts"),
|
|
Index("ix_observations_primitive_ts", "primitive", "ts"),
|
|
UniqueConstraint("evidence_ref", "primitive",
|
|
name="uq_observations_evidence_primitive"),
|
|
)
|
|
```
|
|
|
|
**SQLAlchemy `JSON` not `JSONB`** per the typed-evidence-dicts memory
|
|
rule (dual-backend MySQL + SQLite).
|
|
|
|
**`evidence_ref` is NOT NULL** for DECNET-emitted observations, even
|
|
though BEHAVE's envelope makes it `Optional[str]`. The worker's
|
|
"have we already profiled this session?" check (see Idempotency
|
|
below) keys on `evidence_ref`; if it's NULL the check breaks. The
|
|
shape `shard:{decky}/{service}/{date}.jsonl#sid` is mandatory at the
|
|
worker layer. If a future BEHAVE consumer needs nullable
|
|
evidence_ref, that's a separate observation source with its own
|
|
worker — not this one.
|
|
|
|
**`UniqueConstraint(evidence_ref, primitive)`** enforces idempotency
|
|
at the schema level, so a re-run of the worker on the same shard+sid
|
|
produces a DB-side conflict, not silent duplicate rows. SQLite and
|
|
MySQL both treat distinct (non-NULL) tuples as distinct in unique
|
|
indexes — safe across both backends since `evidence_ref` is
|
|
NOT NULL.
|
|
|
|
**No `_migrate_*` helper.** Pre-v1; `SessionProfile` and its `kd_*`
|
|
columns are deleted from `decnet/web/db/models/attackers.py`
|
|
outright. DEBT-011 (Alembic) remains deferred.
|
|
|
|
### Canonical queries
|
|
|
|
**Latest observation per primitive, for one attacker** (AttackerDetail
|
|
"current state" panel):
|
|
|
|
```sql
|
|
SELECT primitive, value, confidence, ts
|
|
FROM observations
|
|
WHERE attacker_uuid = :uuid
|
|
AND ts = (SELECT MAX(ts) FROM observations o2
|
|
WHERE o2.attacker_uuid = observations.attacker_uuid
|
|
AND o2.primitive = observations.primitive)
|
|
ORDER BY primitive;
|
|
```
|
|
|
|
(SQLite — no `DISTINCT ON`; window-function rewrite available if the
|
|
correlated subquery hot-spots.)
|
|
|
|
**Time-series for one primitive across all sessions of one attacker**
|
|
(for "is this typist drifting" charts, future):
|
|
|
|
```sql
|
|
SELECT ts, value, confidence
|
|
FROM observations
|
|
WHERE attacker_uuid = :uuid AND primitive = :primitive
|
|
ORDER BY ts;
|
|
```
|
|
|
|
## The session-ended handler — riding the existing profiler worker
|
|
|
|
```
|
|
decnet/profiler/
|
|
├── worker.py EXISTING — gains attacker.session.ended subscription
|
|
└── behave_shell/ NEW — pure extraction library (no I/O)
|
|
├── __init__.py
|
|
└── extract.py wraps the engine + disk-reach call site
|
|
|
|
tests/profiler/behave_shell/
|
|
├── __init__.py
|
|
├── test_extract.py unit tests against synthetic event streams
|
|
├── test_calibration_grid.py the five-class regression suite (Phase 5)
|
|
├── test_worker_session_ended_bus.py FakeBus path
|
|
└── test_worker_session_ended_poll.py DECNET_BUS_ENABLED=false path
|
|
```
|
|
|
|
(All tests live under `tests/`, mirroring the source tree per repo
|
|
convention. Existing `tests/profiler/test_session_profile.py` is
|
|
deleted alongside the `SessionProfile` model in Phase 1.)
|
|
|
|
**Trigger.** Subscribe to `attacker.session.ended` on the bus. Poll
|
|
fallback walks `Log` rows where `event_type='session_recorded'` and
|
|
no `observations` row carries the matching `evidence_ref`. Bus path
|
|
ships first; poll fallback ships in the same commit so
|
|
`DECNET_BUS_ENABLED=false` is supported from day one (DEBT-031
|
|
pattern).
|
|
|
|
**Disk-reach.** For each `(decky, service, sid)`, resolve the shard
|
|
via `_find_shard_with_sid` (already shipped, `323077b`). Open the
|
|
JSONL via `decnet/artifacts/paths.py:resolve_artifact_path`
|
|
(DEBT-047 — symlink-escape check, regex validation,
|
|
`ARTIFACTS_ROOT` env override). Slice the per-sid event list. Pass
|
|
to BEHAVE.
|
|
|
|
**Extraction.** Call
|
|
`decnet.profiler.behave_shell.extract_session(events, sid=..., source=...)`.
|
|
Receive `Iterable[Observation]`. Each is registry-validated at
|
|
construction by BEHAVE's `Observation` subclass; DECNET does not
|
|
re-validate.
|
|
|
|
**Resolve `attacker_uuid`.** Sessrec carries `(decky_name, service,
|
|
sid, src_ip, src_port)` per shard line. Resolve src_ip → attacker
|
|
via the existing `attackers.ip` index; create-if-missing per the
|
|
existing observe path. Stamp `identity_ref=NULL` until attribution
|
|
exists.
|
|
|
|
**Bus emission.** For each observation, **DECNET overrides BEHAVE's
|
|
adapter** to preserve sensor-side identifiers across the bus:
|
|
|
|
```python
|
|
# BEHAVE's to_event_payload() excludes id/ts/v because BEHAVE assumes
|
|
# the bus envelope carries them at the Event level. DECNET's bus
|
|
# (DEBT-029) auto-generates fresh id/ts/v on publish — there's no
|
|
# bus.publish overload that accepts envelope-level overrides. Without
|
|
# this merge, BEHAVE's id/ts/v would be silently lost, breaking
|
|
# cross-host dedup and federation gossip.
|
|
payload = to_event_payload(obs) | {"id": obs.id, "ts": obs.ts, "v": obs.v}
|
|
|
|
bus.publish(
|
|
topic = event_topic_for(obs.primitive), # 'attacker.observation.motor.keystroke_cadence'
|
|
payload = payload,
|
|
)
|
|
```
|
|
|
|
Subscribers reconstructing the envelope via
|
|
`from_event_payload(primitive, payload)` see the original BEHAVE id /
|
|
ts / v because they ride along in `payload`. The DECNET-bus Event
|
|
envelope's *own* id/ts/v (auto-generated) are bus-routing concerns,
|
|
distinct from observation identity.
|
|
|
|
**This is a known deviation from BEHAVE's wire-format docstring**
|
|
(`core/decnet_behave_core/spec/envelope.py:77-84`). If DECNET's bus
|
|
later grows envelope-level overrides on `publish()`, revert to the
|
|
upstream contract. Filed as a low-priority follow-up — not blocking.
|
|
|
|
Adapter import path is pure-stdlib — no DECNET imports inside BEHAVE.
|
|
DECNET is the consumer of BEHAVE's contract, never the other way
|
|
around.
|
|
|
|
**Persistence.** All observations from one session — i.e. one
|
|
`(decky, service, sid)` triple — commit as **a single transaction**.
|
|
Either the entire session lands in `observations` or none of it
|
|
does; partial-failure mid-session never leaves a half-profiled
|
|
attacker row.
|
|
|
|
Persist **first**, then publish to the bus best-effort. Bus is
|
|
fire-and-forget (DEBT-029 §6) — a publish failure does **not** roll
|
|
back the persisted rows, and a persist failure means nothing is
|
|
published. DB is the source of truth; the bus is the notification
|
|
layer only. Order matters: a downstream subscriber receiving an
|
|
`attacker.observation.*` event can immediately query the table and
|
|
find it; the inverse (publish-then-persist) would create a window
|
|
where subscribers chase rows that don't exist yet.
|
|
|
|
**Idempotency.** Enforced at the schema level by
|
|
`UniqueConstraint(evidence_ref, primitive)`. Re-running the worker
|
|
on the same shard+sid produces a DB-side conflict per row, which the
|
|
worker handles via `INSERT … ON CONFLICT DO UPDATE` (SQLAlchemy
|
|
upsert). Worker marks a session "profiled" by the existence of any
|
|
row matching its `evidence_ref` — no separate marker column. Because
|
|
the unique index makes accidental duplicates structurally
|
|
impossible, the marker check is honest.
|
|
|
|
## Bus topics
|
|
|
|
Add to `decnet/bus/topics.py`:
|
|
|
|
```python
|
|
ATTACKER_OBSERVATION_PREFIX = "attacker.observation"
|
|
# Wildcard patterns:
|
|
# attacker.observation.motor.*
|
|
# attacker.observation.cognitive.*
|
|
# attacker.observation.> (everything BEHAVE-SHELL emits)
|
|
```
|
|
|
|
Topic shape locked by BEHAVE's `event_topic_for()`; DECNET registers
|
|
the prefix for documentation and pattern-matching only. **Bus auth
|
|
is not topic-level** — per DEBT-029 §2 the bus uses
|
|
kernel-authenticated peer delivery (UNIX socket file permissions),
|
|
not topic ACLs. `bus/topics.py` change co-commits with a
|
|
wiki-checkout `Service-Bus.md` update (memory rule: "Document new
|
|
bus signals in the wiki").
|
|
|
|
## AttackerDetail consumer
|
|
|
|
### REST surface
|
|
|
|
`decnet/web/router/attackers/api_get_attacker_detail.py` swaps the
|
|
`SessionProfile` join for the latest-per-primitive query above.
|
|
Response shape gains:
|
|
|
|
```jsonc
|
|
{
|
|
// ... existing attacker fields ...
|
|
"observations": [
|
|
{
|
|
"primitive": "motor.input_modality",
|
|
"value": "pasted",
|
|
"confidence": 0.91,
|
|
"ts": 1714521660.456,
|
|
"source": "decnet/profiler/behave_shell/extract.py"
|
|
},
|
|
// ... one row per primitive observed for this attacker ...
|
|
]
|
|
}
|
|
```
|
|
|
|
Frontend (`AttackerDetail.tsx`) renders a "Behavioural primitives"
|
|
panel grouped by the registry's top-level domain (`motor.*`,
|
|
`cognitive.*`, `temporal.*`, `operational.*`, `environmental.*`,
|
|
`cultural.*`, `emotional_valence.*`, `toolchain.*`). Day-one render
|
|
priorities for the panel:
|
|
|
|
1. `motor.input_modality` — pasted vs typed vs mixed
|
|
2. `cognitive.feedback_loop_engagement` — closed_loop vs fire_and_forget
|
|
3. `cognitive.command_branch_diversity` — linear_playbook vs adaptive_branching
|
|
4. `cognitive.inter_command_latency_class` — typing_speed / llm_lightweight / llm_heavyweight / long
|
|
5. Everything else, alphabetised by primitive path.
|
|
|
|
These four are the highest-discriminative-value primitives in the
|
|
calibration grid; surfacing them first is what unblocks the "is this
|
|
the same operator class" hover story.
|
|
|
|
### Live-update SSE route
|
|
|
|
`GET /api/v1/attackers/{uuid}/events` — per-attacker SSE stream,
|
|
mirrors the per-topology pattern shipped in DEBT-030.
|
|
The route subscribes to `attacker.observation.*` filtered by
|
|
`identity_ref` / resolved `attacker_uuid`, plus
|
|
`attacker.fingerprint_rotated` / `attacker.scored` for the same
|
|
attacker.
|
|
|
|
Envelope identical to topology events:
|
|
`{v, type, ts, payload}`. Day-one event types:
|
|
`observation.<primitive>`, `fingerprint.rotated`, `attacker.scored`.
|
|
|
|
Auth: `?token=` query-param matching the existing per-topology and
|
|
`/stream` pattern. Snapshot-on-connect serves the latest-per-primitive
|
|
query result so the panel hydrates immediately, then live-forwards
|
|
bus events. 15s keepalive, mirrors the topology route.
|
|
|
|
The global `/stream` is **not** the right fit here — it fans out
|
|
every attacker's events to every subscriber, and the AttackerDetail
|
|
page only cares about one. Per-attacker route, like
|
|
per-topology.
|
|
|
|
## PII discipline
|
|
|
|
Binds at the BEHAVE layer; DECNET does not get to "improve" the
|
|
envelope by reading raw bodies into payloads.
|
|
|
|
- Raw `[t,"i",d]` keystroke events stay on disk. Worker reads,
|
|
extracts, discards.
|
|
- `evidence_ref` is a *pointer* (`shard:path#sid`), never the
|
|
evidence itself.
|
|
- `value` JSON is bounded by the registry's `ValueTypeSpec` — no
|
|
free-form blobs that could smuggle keystrokes.
|
|
- Bigram simhashes (when emitted via `cognitive.*` digraph
|
|
primitives) are *characters*, not *content* — already documented in
|
|
BEHAVE's primitives module.
|
|
|
|
**Canonical PII binding.** The authoritative statement is the module
|
|
docstring at `core/decnet_behave_core/spec/envelope.py:3-19` — it
|
|
forbids raw keystrokes, command bodies, credentials, and payload
|
|
bytes in observation values; `evidence_ref` is a pointer, never the
|
|
evidence. That docstring is binding on this DECNET integration.
|
|
*Not* `BEHAVE-SHELL/scratchpad.md` — scratchpads, by definition,
|
|
aren't binding policy surfaces.
|
|
|
|
## Calibration grid IS the regression test
|
|
|
|
`tests/profiler/behave_shell/test_calibration_grid.py` runs the
|
|
**pure engine** (`behave_shell.extract_session()` called directly,
|
|
no worker, no bus, no DB) against each of the five
|
|
`BEHAVE/prototype_extractors/shell/sessions-2026-05-02-*.jsonl`
|
|
shards (gitignored — fixture path resolved via
|
|
`BEHAVE_CALIBRATION_DIR` env var, skipped if unset). Asserts the
|
|
expected primitive set fires per class:
|
|
|
|
| Shard | Class | Required primitives in output |
|
|
|---|---|---|
|
|
| `sessions-2026-05-02.jsonl` | HUMAN | `motor.input_modality=typed`, `cognitive.inter_command_consistency=bimodal`, `cognitive.feedback_loop_engagement=closed_loop`, `cognitive.command_branch_diversity=adaptive_branching` |
|
|
| `sessions-2026-05-02-with-llm.jsonl` | YOU-sim | `motor.input_modality=pasted`, `motor.paste_burst_rate=occasional`, `cognitive.inter_command_latency_class=typing_speed`, `cognitive.command_branch_diversity=linear_playbook` |
|
|
| `sessions-2026-05-02-new.jsonl` | LW-sim | `motor.input_modality=pasted`, `motor.paste_burst_rate=habitual`, `cognitive.inter_command_latency_class=llm_lightweight`, `cognitive.command_branch_diversity=linear_playbook` |
|
|
| `sessions-2026-05-02-with-claude.jsonl` | CLAUDE-FF | `motor.input_modality=pasted`, `motor.paste_burst_rate=habitual`, `cognitive.inter_command_latency_class=llm_heavyweight`, `cognitive.command_branch_diversity=linear_playbook`, `cognitive.feedback_loop_engagement=fire_and_forget` |
|
|
| `sessions-2026-05-02-closed-loop.jsonl` | CLAUDE-CL | `motor.input_modality=pasted`, `motor.paste_burst_rate=habitual`, `cognitive.inter_command_latency_class=long`, `cognitive.command_branch_diversity=adaptive_branching`, `cognitive.feedback_loop_engagement=closed_loop` |
|
|
|
|
Any extractor change that breaks one of these classifications fails
|
|
CI. The grid is the discriminative-power floor — calibration
|
|
refinement can *add* primitives, never silently *drop* them.
|
|
|
|
## Phase plan
|
|
|
|
Per the "commit per task" memory rule, each phase ships as one commit
|
|
with its own tests.
|
|
|
|
### Phase 1 — DECNET-side storage (no BEHAVE coupling yet)
|
|
|
|
- New `observations` table + SQLModel + repository methods.
|
|
- Drop `SessionProfile` + `kd_*` columns from
|
|
`decnet/web/db/models/attackers.py`.
|
|
- AttackerDetail API switches to the latest-per-primitive query.
|
|
Returns empty `observations: []` since nothing populates the table.
|
|
- `decnet/bus/topics.py` registers `attacker.observation.*` prefix.
|
|
- Tests: SQLModel CRUD, latest-per-primitive query against fixture
|
|
rows, empty-attacker contract.
|
|
|
|
### Phase 2 — DECNET extraction engine (`decnet/profiler/behave_shell/`)
|
|
|
|
- Production extractor written against the BEHAVE spec, pure library
|
|
(no I/O).
|
|
- One feature-family module per `_features/{motor,cognitive,temporal,...}.py`.
|
|
- Public entry: `extract_session(events, *, sid, source) -> Iterable[Observation]`.
|
|
- Tests in `tests/profiler/behave_shell/_features/`: per-feature unit
|
|
tests against synthetic event streams. The calibration-grid suite
|
|
(Phase 5) is the integration test.
|
|
- This phase has its own design surface — see `BEHAVE-EXTRACTOR.md`
|
|
(filed as a sibling doc when Phase 1 lands). Phases 1 and 2 are
|
|
largely independent; can run in parallel.
|
|
|
|
### Phase 3 — BEHAVE pin
|
|
|
|
- `pyproject.toml` pins `decnet-behave-core` and `decnet-behave-shell`
|
|
at whatever versions the engine settles on.
|
|
- CI install-time smoke: registry imports cleanly, envelope validates
|
|
a known-good observation.
|
|
|
|
### Phase 4 — Wire the trigger into the existing profiler worker
|
|
|
|
- `decnet/profiler/worker.py` gains an `attacker.session.ended`
|
|
subscription handler.
|
|
- Handler does: resolve shard via disk-reach → call
|
|
`behave_shell.extract_session()` → upsert into `observations` table
|
|
→ publish each observation on the bus.
|
|
- Poll fallback for `DECNET_BUS_ENABLED=false`.
|
|
- Trigger isolation: handler exceptions logged, do not affect the
|
|
existing scoring tick.
|
|
- Tests in `tests/profiler/behave_shell/`: FakeBus path, poll-only
|
|
path, disk-reach error paths, idempotency on re-run.
|
|
- **No new systemd unit.** The existing `decnet-profiler.service`
|
|
already supervises this code.
|
|
|
|
### Phase 5 — Calibration regression suite + UI surface
|
|
|
|
- `tests/profiler/behave_shell/test_calibration_grid.py` against all
|
|
five BEHAVE shards.
|
|
- New `GET /api/v1/attackers/{uuid}/events` SSE route (mirrors the
|
|
per-topology pattern from DEBT-030); snapshot-on-connect +
|
|
bus-forwarded `attacker.observation.*` events. Tests in
|
|
`tests/api/attackers/test_events_stream.py`.
|
|
- AttackerDetail.tsx renders the Behavioural primitives panel and
|
|
consumes the SSE route for live updates.
|
|
- Frontend Vitest coverage for the panel (DEBT-043 harness, shipped).
|
|
|
|
### Phase 6 — Live smoke
|
|
|
|
- Ship a decky, run a real SSH session from each calibration class
|
|
manually, disconnect, observe `observations` rows + bus events +
|
|
AttackerDetail panel.
|
|
- Document the smoke procedure in
|
|
`scripts/behave_shell/smoke.sh` (parallel to
|
|
`scripts/bus/smoke-mutator.sh` — per-feature dirs).
|
|
|
|
## Out of scope
|
|
|
|
Filed for future paydown when they bite. Do not let them creep into
|
|
this integration.
|
|
|
|
- **Attribution engine.** Consumes `attacker.observation.*`, emits
|
|
`attribution.profile.candidate.*`. BEHAVE explicitly separates
|
|
observation from attribution.
|
|
- **Federation gossip** of observations across swarm hosts.
|
|
- **Backfill** over historical shards (one-shot script when the
|
|
table lands; not a worker feature).
|
|
- **Webhook export** of observation streams (rides DEBT-037).
|
|
- **Observation retention / vacuum.** Pre-v1, no users to mislead;
|
|
filed when storage actually pressures.
|
|
- **`SessionProfile` data migration.** None — table ships empty
|
|
today, drop is destructive but lossless.
|
|
- **Cross-domain BEHAVE** (BEHAVE-TEXT integration for stylometric
|
|
analysis of attacker-typed messages, e.g. captured emails). Same
|
|
`observations` table will accept those envelopes when their primitive
|
|
registry is registered, but the wiring is a separate paydown.
|
|
|
|
## Resolved decisions (formerly open questions)
|
|
|
|
- **Q1 — engine location.** RESOLVED: BEHAVE's prototype is reference
|
|
code only, never imported by DECNET. The production extraction
|
|
engine lives in `decnet/profiler/behave_shell/` as a sublibrary of
|
|
the existing profiler worker — no new daemon, no new systemd unit.
|
|
(See "BEHAVE is the spec. DECNET is the engine.")
|
|
- **Q2 — emission granularity.** RESOLVED: **per-(sid, primitive).**
|
|
Every session emits its full primitive set; every emission
|
|
persists. The schema already supports it; this just locks in the
|
|
worker write loop. *More detail the better.*
|
|
- **Q3 — cross-session aggregation, day one.** RESOLVED: latest wins
|
|
per primitive in the AttackerDetail "current state" query. Simple,
|
|
honest, easy to reason about.
|
|
|
|
## Real open question — Cross-session aggregation, the right way
|
|
|
|
Q3's "latest wins" is a stopgap. The actual question is harder and
|
|
deserves its own design pass before AttackerDetail starts surfacing
|
|
attribution-flavoured claims:
|
|
|
|
> **When two sessions from the same attacker (or identity) emit
|
|
> conflicting values for the same primitive, what does the
|
|
> attacker-level view say?**
|
|
|
|
Concrete cases:
|
|
|
|
- Session A: `motor.input_modality = typed` (conf 0.92).
|
|
Session B (next day): `motor.input_modality = pasted` (conf 0.88).
|
|
Is this attacker `mixed`? Or did they switch tooling? Or did a
|
|
*different operator* take over the same credentialed access?
|
|
- `cognitive.feedback_loop_engagement` flips from `closed_loop` to
|
|
`fire_and_forget` between two sessions. Is this fatigue, a
|
|
handoff (`operational.multi_actor_indicators=handoff_detected`?),
|
|
or a script taking over from a human?
|
|
- `cognitive.command_branch_diversity = unknown` in a short session
|
|
vs `adaptive_branching` in a long session. Latest-wins would
|
|
collapse this to `unknown` if the short session lands second —
|
|
exactly the wrong answer.
|
|
|
|
**This is genuinely an attribution-engine concern**, not an
|
|
extraction concern. BEHAVE is firm on that bright line. The clean
|
|
answer is:
|
|
|
|
1. **DECNET stores all observations** (per-sid, per-primitive — Q2).
|
|
2. **AttackerDetail's day-one "current state" query is latest-wins**
|
|
(Q3) — not because it's right, but because it's *honestly
|
|
transparent* about being naïve.
|
|
3. **The right answer ships with the attribution engine** as a
|
|
separate paydown — likely as new `attribution.profile.*` topics
|
|
that emit a *derived* per-attacker primitive map with explicit
|
|
merge semantics (`stable` / `drifting` / `conflicted` /
|
|
`multi_actor`). Day-zero, that engine doesn't exist; day-one,
|
|
AttackerDetail just shows raw latest values + a "N
|
|
observations" hover.
|
|
|
|
Filed as **DEBT-051 — Cross-session BEHAVE primitive aggregation
|
|
(attribution engine)** when this doc is reviewed. Out of scope for
|
|
this integration; explicitly listed under "Out of scope" above.
|
|
|
|
---
|
|
|
|
**Owner:** ANTI.
|
|
**Implementation gate:** this doc reviewed → Phase 1 starts.
|