Skip to content

Commit c596350

Browse files
committed
feat: discovery-driven forensics hub + evidence board (RFC-13 Phase 6, #68)
forensics.investigate.hub runs the forensics pipeline as a discovery-driven dispatch over the shared substrate, reusing every existing stage handler unchanged. make_evidence_condition (new, content-aware) matches a discovery's evidence_type, so a discovered disk image opens the disk and binary lanes and a pcap opens the network lane, off the existing _LANE_EVIDENCE_TYPES classification. Each phase adapter runs the real stage and only overrides the transition back to the hub; each lane phase scopes state_collection to its single lane via active_lanes. The deterministic tail (deep_analysis, promotion, resolution, writeup) runs unconditionally after the lanes. record_evidence posts a discovered evidence item to the shared ledger as the cross-branch evidence board (discovery entry carrying type, path, source; idempotent per path). No collector machinery is rewritten and the live FORENSICS_DISPATCHER_V1 is untouched -- the hub ships bound nowhere, enabled by an operator seed rebind after smoke, like the malware and vr hubs. 5 tests: hub structure (lanes + tail), disk_image activates disk, pcap activates network, evidence board records provenance + is idempotent, and no matching evidence falls through to the unconditional tail.
1 parent 67214be commit c596350

4 files changed

Lines changed: 296 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ operator action.
8989
-- so it activates only once the panel confirms an exploitable finding by
9090
quorum. Reuses the Phase 0-4 substrate with no new platform code; ships
9191
bound nowhere live (operator rebind after smoke).
92+
- Discovery-driven forensics hub + evidence board (RFC-13 #68,
93+
`forensics.investigate.hub`): a content-aware `make_evidence_condition`
94+
matches a discovery's `evidence_type`, so a discovered disk image opens
95+
the disk and binary lanes and a discovered pcap opens the network lane,
96+
reusing the existing `_LANE_EVIDENCE_TYPES` classification. The hub runs
97+
the proven forensics stages unchanged -- each phase adapter runs the real
98+
stage and only overrides the transition back to the hub, and each lane
99+
phase scopes `state_collection` to its single lane via `active_lanes` --
100+
then runs the deterministic tail (deep_analysis, promotion, resolution,
101+
writeup) unconditionally. `record_evidence` posts a discovered evidence
102+
item to the shared ledger as the cross-branch evidence board. No
103+
collector machinery is rewritten and the live `FORENSICS_DISPATCHER_V1`
104+
is untouched; the hub ships bound nowhere (operator rebind after smoke).
92105
- Platform agent runtime (RFC-03): `AgentTurnRunnerBase`,
93106
`ToolExecutorHelpersBase`, the shared turn helpers, and platform bases
94107
for the pattern extractor, claim verifier, synthesis runner, persona
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Forensics dispatch-hub graph (opt-in) over the shared substrate.
2+
3+
RFC-13 (#68). Where ``FORENSICS_DISPATCHER_V1`` routes to a linear mode
4+
pipeline, this graph runs the same proven stage handlers as a
5+
discovery-driven dispatch: setup (intake), then the hub, then a stage,
6+
then back to the hub, until emit. Intake enumerates evidence and posts the
7+
active lanes; the hub then activates a collector lane only when a matching
8+
evidence type was discovered -- a disk image opens the disk (and binary)
9+
lane, a pcap opens the network lane -- reusing the existing
10+
``_LANE_EVIDENCE_TYPES`` classification. The deterministic post-collection
11+
tail (deep_analysis, promotion, resolution, writeup) runs unconditionally
12+
after the lanes, in order.
13+
14+
This reuses every existing forensics stage handler unchanged: each phase
15+
adapter runs the real stage and only overrides the next transition back to
16+
the hub, and each lane phase scopes ``state_collection`` to its single lane
17+
via ``active_lanes``. No collector machinery is rewritten and the live
18+
``FORENSICS_DISPATCHER_V1`` is untouched; the definition ships bound
19+
nowhere and is enabled by an operator seed rebind after a smoke.
20+
21+
The shared ledger is the evidence board: :func:`record_evidence` posts a
22+
discovered evidence item as a ledger ``discovery`` with its type, path, and
23+
source so the hub conditions (and other branches) read a common board.
24+
"""
25+
from __future__ import annotations
26+
27+
from typing import Any, cast
28+
29+
from sqlalchemy.ext.asyncio import AsyncSession
30+
31+
from aila.modules.forensics.workflow.definitions import (
32+
_build_services,
33+
_state_response_emit,
34+
)
35+
from aila.modules.forensics.workflow.states.collection import (
36+
_LANE_EVIDENCE_TYPES,
37+
state_collection,
38+
)
39+
from aila.modules.forensics.workflow.states.deep_analysis import state_deep_analysis
40+
from aila.modules.forensics.workflow.states.intake import state_intake
41+
from aila.modules.forensics.workflow.states.promotion import state_promotion
42+
from aila.modules.forensics.workflow.states.resolution import state_resolution
43+
from aila.modules.forensics.workflow.states.writeup import state_writeup
44+
from aila.platform.services.ledger import LedgerService, make_evidence_condition
45+
from aila.platform.workflows.phase_graph import (
46+
PhaseSpec,
47+
build_dispatch_workflow,
48+
)
49+
from aila.platform.workflows.types import HandlerFn, StateResult
50+
51+
__all__ = [
52+
"FORENSICS_HUB_PHASES",
53+
"FORENSICS_INVESTIGATE_HUB",
54+
"record_evidence",
55+
]
56+
57+
# Hub phase name -> collection lane key. The "binary" phase drives the
58+
# "binary_analysis" collection lane (capa/FLOSS/strings on samples the disk
59+
# lane surfaces).
60+
_PHASE_LANE: dict[str, str] = {
61+
"disk": "disk",
62+
"memory": "memory",
63+
"network": "network",
64+
"log": "log",
65+
"binary": "binary_analysis",
66+
}
67+
68+
# Hub phase name -> deterministic tail stage handler (unconditional).
69+
_TAIL_HANDLERS: dict[str, HandlerFn] = {
70+
"deep_analysis": state_deep_analysis,
71+
"promotion": state_promotion,
72+
"resolution": state_resolution,
73+
"writeup": state_writeup,
74+
}
75+
76+
77+
async def record_evidence(
78+
investigation_id: str,
79+
author_branch_id: str,
80+
evidence_type: str,
81+
path: str,
82+
source: str,
83+
*,
84+
session: AsyncSession | None = None,
85+
) -> int:
86+
"""Post a discovered evidence item to the shared ledger (evidence board).
87+
88+
Recorded as a ``discovery`` entry carrying the evidence type, path, and
89+
source so the hub's evidence conditions and other branches read one
90+
board. Idempotency-keyed by path so re-enumerating the same evidence
91+
does not double-post.
92+
"""
93+
return await LedgerService().append_general(
94+
investigation_id,
95+
author_branch_id,
96+
"discovery",
97+
{"evidence_type": evidence_type, "path": path, "source": source},
98+
idempotency_key=f"evidence:{path}",
99+
session=session,
100+
)
101+
102+
103+
def _setup_builder(next_state: str) -> HandlerFn:
104+
"""Run intake, then transition to the hub instead of a static edge."""
105+
async def _handler(state_input: dict[str, Any], services: Any) -> StateResult:
106+
result = await state_intake(state_input, services)
107+
return StateResult(
108+
next_state=next_state, output={**state_input, **result.output},
109+
)
110+
return _handler
111+
112+
113+
def _loop_builder(phase: PhaseSpec, next_state: str) -> HandlerFn:
114+
"""Run a lane collector or a tail stage, then loop back to the hub."""
115+
lane = _PHASE_LANE.get(phase.name)
116+
tail = _TAIL_HANDLERS.get(phase.name)
117+
118+
async def _handler(state_input: dict[str, Any], services: Any) -> StateResult:
119+
if lane is not None:
120+
scoped = {**state_input, "active_lanes": [lane]}
121+
result = await state_collection(scoped, services)
122+
else:
123+
result = await tail(state_input, services)
124+
output = result.output if isinstance(result, StateResult) else dict(result)
125+
return StateResult(next_state=next_state, output={**state_input, **output})
126+
return _handler
127+
128+
129+
FORENSICS_HUB_PHASES: tuple[PhaseSpec, ...] = (
130+
PhaseSpec(
131+
name="disk",
132+
condition=make_evidence_condition(_LANE_EVIDENCE_TYPES["disk"]),
133+
trust="advisory",
134+
),
135+
PhaseSpec(
136+
name="memory",
137+
condition=make_evidence_condition(_LANE_EVIDENCE_TYPES["memory"]),
138+
trust="advisory",
139+
),
140+
PhaseSpec(
141+
name="network",
142+
condition=make_evidence_condition(_LANE_EVIDENCE_TYPES["network"]),
143+
trust="advisory",
144+
),
145+
PhaseSpec(
146+
name="log",
147+
condition=make_evidence_condition(_LANE_EVIDENCE_TYPES["log"]),
148+
trust="advisory",
149+
),
150+
PhaseSpec(
151+
name="binary",
152+
condition=make_evidence_condition(_LANE_EVIDENCE_TYPES["binary_analysis"]),
153+
trust="advisory",
154+
),
155+
PhaseSpec(name="deep_analysis"),
156+
PhaseSpec(name="promotion"),
157+
PhaseSpec(name="resolution"),
158+
PhaseSpec(name="writeup"),
159+
)
160+
161+
162+
FORENSICS_INVESTIGATE_HUB = build_dispatch_workflow(
163+
"forensics.investigate.hub",
164+
FORENSICS_HUB_PHASES,
165+
services_factory=_build_services,
166+
setup_builder=_setup_builder,
167+
loop_builder=_loop_builder,
168+
emit_handler=cast("HandlerFn", _state_response_emit),
169+
)

src/aila/platform/services/ledger.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from __future__ import annotations
2121

2222
import json
23-
from collections.abc import AsyncGenerator, Awaitable, Callable
23+
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
2424
from contextlib import asynccontextmanager
2525
from datetime import datetime
2626
from typing import Any
@@ -39,6 +39,7 @@
3939
"LedgerPermissionError",
4040
"LedgerService",
4141
"make_discovery_condition",
42+
"make_evidence_condition",
4243
]
4344

4445
_UQ_IDEM = "uq_investigation_ledger_idem"
@@ -415,3 +416,44 @@ async def _condition(state_input: dict[str, Any]) -> tuple[bool, str]:
415416
return False, f"no {kind} entries on ledger yet"
416417

417418
return _condition
419+
420+
421+
def make_evidence_condition(
422+
evidence_types: str | Iterable[str],
423+
*,
424+
confirmed_only: bool = False,
425+
input_key: str = "investigation_id",
426+
) -> Callable[[dict[str, Any]], Awaitable[tuple[bool, str]]]:
427+
"""Build a dispatch-hub condition that fires on a discovered evidence type.
428+
429+
A content-aware sibling of :func:`make_discovery_condition`: it reads
430+
``discovery`` entries and matches each entry's ``payload["evidence_type"]``
431+
against *evidence_types*. The forensics hub uses it so a discovered disk
432+
image activates the disk lane and a discovered pcap activates the network
433+
lane, off the shared ledger (RFC-13 #68). ``confirmed_only`` restricts to
434+
quorum-confirmed discoveries and honors the same ratified-replan relax
435+
flag as :func:`make_discovery_condition`.
436+
"""
437+
wanted = {evidence_types} if isinstance(evidence_types, str) else set(evidence_types)
438+
439+
async def _condition(state_input: dict[str, Any]) -> tuple[bool, str]:
440+
investigation_id = state_input.get(input_key)
441+
if not investigation_id:
442+
return False, f"no {input_key} on dispatch input"
443+
effective_confirmed = confirmed_only and not state_input.get(
444+
"_dispatch_replan_relax"
445+
)
446+
entries = await LedgerService().read_general(
447+
str(investigation_id),
448+
kinds=["discovery"],
449+
confirmed_only=effective_confirmed,
450+
)
451+
matched = [
452+
e for e in entries
453+
if (e.get("payload") or {}).get("evidence_type") in wanted
454+
]
455+
if matched:
456+
return True, f"{len(matched)} discovery entries matching {sorted(wanted)}"
457+
return False, f"no discovery matching {sorted(wanted)}"
458+
459+
return _condition

tests/test_forensics_hub.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Forensics dispatch-hub: evidence-driven lane activation + evidence board (RFC-13 Phase 6)."""
2+
from __future__ import annotations
3+
4+
from aila.modules.forensics.workflow.definitions_hub import (
5+
FORENSICS_HUB_PHASES,
6+
FORENSICS_INVESTIGATE_HUB,
7+
record_evidence,
8+
)
9+
from aila.platform.services.ledger import LedgerService
10+
from aila.platform.workflows.phase_graph import DISPATCH_STATE, make_dispatch_router
11+
12+
13+
def test_hub_has_dispatch_lane_and_tail_states() -> None:
14+
states = FORENSICS_INVESTIGATE_HUB.states
15+
assert DISPATCH_STATE in states
16+
for name in ("disk", "memory", "network", "log", "binary"):
17+
assert name in states
18+
for name in ("deep_analysis", "promotion", "resolution", "writeup"):
19+
assert name in states
20+
assert FORENSICS_INVESTIGATE_HUB.definition_id == "forensics.investigate.hub"
21+
22+
23+
async def test_disk_image_activates_disk_lane(test_db) -> None:
24+
del test_db
25+
inv = "inv-fx-disk"
26+
await record_evidence(inv, "intake", "disk_image", "/ev/img.E01", "case-1")
27+
router = make_dispatch_router(FORENSICS_HUB_PHASES)
28+
result = await router({"investigation_id": inv, "_dispatch_visited": []}, None)
29+
assert result.next_state == "disk"
30+
31+
32+
async def test_pcap_activates_network_lane(test_db) -> None:
33+
del test_db
34+
inv = "inv-fx-pcap"
35+
await record_evidence(inv, "intake", "pcap", "/ev/capture.pcap", "case-2")
36+
router = make_dispatch_router(FORENSICS_HUB_PHASES)
37+
# Disk/memory lanes have no matching evidence, so the hub skips them and
38+
# activates the network lane.
39+
result = await router(
40+
{"investigation_id": inv, "_dispatch_visited": ["disk", "memory"]}, None,
41+
)
42+
assert result.next_state == "network"
43+
44+
45+
async def test_evidence_board_records_provenance(test_db) -> None:
46+
del test_db
47+
inv = "inv-fx-board"
48+
entry_id = await record_evidence(
49+
inv, "intake", "memory_dump", "/ev/mem.raw", "case-3",
50+
)
51+
rows = await LedgerService().read_general(inv, kinds=["discovery"])
52+
assert len(rows) == 1
53+
payload = rows[0]["payload"]
54+
assert rows[0]["id"] == entry_id
55+
assert payload["evidence_type"] == "memory_dump"
56+
assert payload["path"] == "/ev/mem.raw"
57+
assert payload["source"] == "case-3"
58+
# Re-recording the same path is idempotent (no double-post).
59+
await record_evidence(inv, "intake", "memory_dump", "/ev/mem.raw", "case-3")
60+
rows_again = await LedgerService().read_general(inv, kinds=["discovery"])
61+
assert len(rows_again) == 1
62+
63+
64+
async def test_no_matching_evidence_falls_through_to_tail(test_db) -> None:
65+
del test_db
66+
inv = "inv-fx-tail"
67+
# No evidence discovered: every lane condition is false, so the hub
68+
# activates the first unconditional tail stage.
69+
router = make_dispatch_router(FORENSICS_HUB_PHASES)
70+
result = await router({"investigation_id": inv, "_dispatch_visited": []}, None)
71+
assert result.next_state == "deep_analysis"

0 commit comments

Comments
 (0)