Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ The NIST Data Leakage case has a [detailed accuracy report](examples/ndlc/ACCURA

## How It Works

![Mulder Architecture and Security Boundaries](docs/images/diagram.png)

Each investigation runs through five phases with quality gates between them. Phases 2-4 use a plan-and-execute pipeline with three specialized roles (planner, executor, analyst) that can optionally be assigned to different models for cost optimization.

1. **Catalog** - scan evidence directory, classify file types, identify distinct systems
Expand Down
51 changes: 7 additions & 44 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,7 @@ Mulder is a forensic investigation platform consisting of two core components: a

## System Overview

```mermaid
flowchart TB
subgraph container [Docker Container]
CLI["mulder CLI"]
Orchestrator["Orchestrator\n(multi-phase pipeline)"]
AgentSDK["Agent SDK\n(Session Runtime)"]
MCPServer["MCP Server\n(FastMCP, 140+ tools)"]
DB["SQLite + FTS5\n(per-case database)"]
AuditLog["Audit Log\n(append-only JSONL)"]
Extractors["Extractors\n(forensic binaries)"]
Reports["Report Renderer\n(HTML + Markdown)"]
end

subgraph binaries [Forensic Toolchain]
Vol3["Volatility 3"]
TSK["Sleuthkit"]
Plaso["Plaso"]
Hayabusa["Hayabusa"]
YARA["YARA"]
BulkExt["bulk_extractor"]
EZTools["EZ Tools"]
Others["60+ more tools"]
end

Evidence["/evidence\n(read-only mount)"]

CLI --> Orchestrator
Orchestrator --> AgentSDK
AgentSDK -->|"MCP (stdio)"| MCPServer
MCPServer --> Extractors
Extractors --> binaries
Extractors -->|"read"| Evidence
MCPServer -->|"read/write"| DB
MCPServer -->|"append"| AuditLog
MCPServer --> Reports
Reports -->|"reads"| DB
Reports -->|"reads"| AuditLog
```
![Mulder Architecture and Security Boundaries](images/diagram.png)

## MCP Server Architecture

Expand Down Expand Up @@ -399,15 +362,15 @@ The orchestrator displays a real-time terminal dashboard using Rich's Live displ
```
+----------------------------------------- Mulder ------------------------------------------+
| [3/7] Phase 2: Deep Extraction: HOST01 |
| claude-sonnet-4-6 | max turns: 75 |
| claude-opus-4-6 | max turns: 75 |
| Tools: 47 Findings: 5 Tokens: 1.2M 20.1K/min |
| CPU: 45% MEM: 6.2/16 GB (39%) 12:34 |
| sonnet-4-6 890K in / 312K out 1.2M |
| opus-4-6 890K in / 312K out 1.2M |
| haiku-4-5 15K in / 3K out 18K |
+-------------------------------------------------------------------------------------------+
| ========================================================== |
| [3/7] Phase 2: Deep Extraction: HOST01 |
| Model: claude-sonnet-4-6 | Max turns: 75 |
| Model: claude-opus-4-6 | Max turns: 75 |
| > run_volatility_batch |
| > run_evtx_parser |
| [HIGH] Persistence Mechanism Detected |
Expand Down Expand Up @@ -562,14 +525,14 @@ The orchestrator uses a planner/executor/analyst role system for model assignmen

| CLI Flag | Role | Default Model | Responsibility |
|----------|------|--------------|----------------|
| `--planner-model` | Planner | `claude-sonnet-4-6` | Decides what tools to run, produces execution plans |
| `--planner-model` | Planner | `claude-opus-4-6` | Decides what tools to run, produces execution plans |
| `--executor-model` | Executor | `claude-haiku-4-5` | Calls tools mechanically, manages waits and retries |
| `--analyst-model` | Analyst | `claude-sonnet-4-6` | Queries indexed data, reasons about evidence, submits findings |
| `--analyst-model` | Analyst | `claude-opus-4-6` | Queries indexed data, reasons about evidence, submits findings |
| `--model` | Fallback | None | Sets all roles when per-role flags are not specified |

Single-mode phases map to roles: catalog uses the planner model, report uses the analyst model. Per-phase overrides can be specified in a YAML config file via `--config`.

All roles inherit from `--model` if not specified individually, enabling single-model deployments (e.g., Bedrock or Vertex with one model identifier).
All roles inherit from `--model` if not specified individually. Model IDs are passed through to the SDK exactly as specified, with no automatic translation between provider formats. Vertex users must include the `@version` suffix (e.g. `claude-opus-4-6@20250514`) and Bedrock users must include the `us.anthropic.` prefix (e.g. `us.anthropic.claude-opus-4-6`).

## Audit and Provenance

Expand Down
Binary file added docs/images/diagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ docker run -it --privileged \
ghcr.io/calebevans/mulder:1.3
```

Model IDs are passed through to the SDK exactly as specified, with no automatic translation or mapping. When using Vertex, you must provide the full Vertex model ID including the `@version` suffix (e.g. `--model claude-opus-4-6@20250514`). If you omit `--model`, the built-in defaults (`claude-opus-4-6` for planner/analyst, `claude-haiku-4-5` for executor) are used.

| Variable | Description |
|----------|-------------|
| `CLAUDE_CODE_USE_VERTEX` | Set to `1` to enable Vertex AI |
Expand All @@ -138,6 +140,8 @@ docker run -it --privileged \
ghcr.io/calebevans/mulder:1.3
```

Model IDs are passed through to the SDK exactly as specified, with no automatic translation or mapping. When using Bedrock, you must provide the full Bedrock model ID with the `us.anthropic.` prefix (e.g. `--model us.anthropic.claude-opus-4-6`). If you omit `--model`, the built-in defaults (`claude-opus-4-6` for planner/analyst, `claude-haiku-4-5` for executor) are used.

| Variable | Description |
|----------|-------------|
| `CLAUDE_CODE_USE_BEDROCK` | Set to `1` to enable Bedrock |
Expand Down Expand Up @@ -194,8 +198,8 @@ Mulder uses three agent roles (planner, executor, analyst), and each can use a d
```bash
mulder investigate /evidence my-case \
--executor-model bedrock/meta.llama3-1-70b-instruct-v1:0 \
--planner-model claude-sonnet-4-6 \
--analyst-model claude-sonnet-4-6
--planner-model claude-opus-4-6 \
--analyst-model claude-opus-4-6
```

### Local Models with Ollama
Expand Down Expand Up @@ -288,9 +292,9 @@ Runs a full multi-phase forensic investigation.
| Option | Default | Description |
|--------|---------|-------------|
| `--model` | None | Fallback model for all roles |
| `--planner-model` | `claude-sonnet-4-6` | Model for planner agents |
| `--planner-model` | `claude-opus-4-6` | Model for planner agents |
| `--executor-model` | `claude-haiku-4-5` | Model for executor agents |
| `--analyst-model` | `claude-sonnet-4-6` | Model for analyst agents |
| `--analyst-model` | `claude-opus-4-6` | Model for analyst agents |
| `--config` | None | YAML config file for models and settings |
| `--effort` | `max` | Effort level (`max`, `xhigh`, `high`) |
| `--workers` | `3` | Max concurrent extraction sessions |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "mulder-mcp"
version = "1.3.0"
version = "1.3.1"
description = "Custom MCP server for SANS SIFT Workstation forensic investigations"
requires-python = ">=3.10"
license = "Apache-2.0"
Expand Down
52 changes: 49 additions & 3 deletions src/mulder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import sys
from pathlib import Path

import click
Expand All @@ -11,6 +12,15 @@
from mulder.patterns import DEFAULT_DB_DIR


def _is_interactive() -> bool:
"""Check if stderr is connected to an interactive terminal.

Returns:
True if stderr is a TTY (interactive session), False if piped or in CI.
"""
return hasattr(sys.stderr, "isatty") and sys.stderr.isatty()


@click.group()
@click.version_option(version=__version__, prog_name="mulder")
def cli() -> None:
Expand Down Expand Up @@ -275,9 +285,9 @@ def investigate(

\b
mulder investigate /evidence Rocba \\
--planner-model claude-sonnet-4-6 \\
--planner-model claude-opus-4-6 \\
--executor-model claude-haiku-4-5 \\
--analyst-model claude-sonnet-4-6
--analyst-model claude-opus-4-6

Non-Claude models are supported via LiteLLM proxy (auto-started when
a model ID uses a provider prefix like bedrock/ or openai/).
Expand Down Expand Up @@ -334,7 +344,43 @@ def investigate(
case_id=case_id,
)

result = asyncio.run(orchestrator.run())
from mulder.orchestrator.errors import (
AuthenticationError,
ModelNotAvailableError,
)

try:
result = asyncio.run(orchestrator.run())
except AuthenticationError as exc:
orchestrator.dashboard.stop()
click.echo(f"\nError: {exc}", err=True)
if exc.suggestion:
click.echo(f"\n{exc.suggestion}", err=True)
raise SystemExit(2) from None
except ModelNotAvailableError as exc:
orchestrator.dashboard.stop()
click.echo(f"\nError: {exc}", err=True)
if exc.alternative:
if _is_interactive():
if click.confirm(
f"Would you like to try {exc.alternative} instead?",
default=True,
):
click.echo(
f"\nRe-run with: mulder investigate ... --model {exc.alternative}",
err=True,
)
else:
click.echo(
f"\nTry: mulder investigate ... --model {exc.alternative}",
err=True,
)
else:
click.echo(
"\nSpecify a different model with --model <model-id>",
err=True,
)
raise SystemExit(2) from None

orchestrator.dashboard.print_summary(result)

Expand Down
14 changes: 12 additions & 2 deletions src/mulder/extractors/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ def classify(self, evidence_root: Path) -> list[ClassifiedEvidence]:
seen_log_dirs: set[Path] = set()
excluded_dirs: set[Path] = set()

for item in sorted(evidence_root.rglob("*")):
try:
all_items = sorted(evidence_root.rglob("*"))
except PermissionError as exc:
logger.warning("Permission denied during evidence walk: %s", exc)
all_items = []

for item in all_items:
if _is_hidden(item):
continue
if self._is_excluded(item, evidence_root):
Expand All @@ -187,7 +193,11 @@ def classify(self, evidence_root: Path) -> list[ClassifiedEvidence]:
logger.debug("Auto-excluding directory: %s", item)
excluded_dirs.add(item)
continue
self._process_item(item, results, seen_log_dirs)
try:
self._process_item(item, results, seen_log_dirs)
except (PermissionError, OSError) as exc:
logger.warning("Skipping inaccessible file %s: %s", item, exc)
continue

return results

Expand Down
55 changes: 55 additions & 0 deletions src/mulder/orchestrator/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,56 @@
"""Multi-pass forensic investigation orchestrator using the Claude Agent SDK."""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from mulder.orchestrator.errors import AuthenticationError, ModelNotAvailableError
from mulder.orchestrator.evidence import EvidenceContext, ServerBridge
from mulder.orchestrator.log_tailer import LogTailer
from mulder.orchestrator.roles import RoleRunner
from mulder.orchestrator.runner import Orchestrator
from mulder.orchestrator.session import SessionExecutor

__all__ = [
"AuthenticationError",
"EvidenceContext",
"LogTailer",
"ModelNotAvailableError",
"Orchestrator",
"RoleRunner",
"ServerBridge",
"SessionExecutor",
]


def __getattr__(name: str) -> object:
"""Lazy-load public symbols to avoid import-order issues with SDK stubs."""
if name in ("AuthenticationError", "ModelNotAvailableError"):
from mulder.orchestrator.errors import (
AuthenticationError,
ModelNotAvailableError,
)

return AuthenticationError if name == "AuthenticationError" else ModelNotAvailableError
if name == "Orchestrator":
from mulder.orchestrator.runner import Orchestrator

return Orchestrator
if name == "SessionExecutor":
from mulder.orchestrator.session import SessionExecutor

return SessionExecutor
if name == "RoleRunner":
from mulder.orchestrator.roles import RoleRunner

return RoleRunner
if name in ("EvidenceContext", "ServerBridge"):
from mulder.orchestrator.evidence import EvidenceContext, ServerBridge

return EvidenceContext if name == "EvidenceContext" else ServerBridge
if name == "LogTailer":
from mulder.orchestrator.log_tailer import LogTailer

return LogTailer
raise AttributeError(f"module 'mulder.orchestrator' has no attribute {name!r}")
43 changes: 43 additions & 0 deletions src/mulder/orchestrator/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Fatal configuration error types for the investigation orchestrator.

These errors represent non-retryable failures that should abort the
investigation immediately with actionable user guidance. They bypass
the phase retry loop entirely.
"""

from __future__ import annotations


class AuthenticationError(Exception):
"""SDK authentication failed. Not retryable."""

def __init__(self, message: str, suggestion: str = "") -> None:
"""Initialize with the raw error and a user-facing suggestion.

Args:
message: The original error text from the SDK.
suggestion: Actionable fix for the user.
"""
super().__init__(message)
self.suggestion = suggestion


class ModelNotAvailableError(Exception):
"""Configured model is not available on the provider. Not retryable."""

def __init__(
self,
message: str,
model: str = "",
alternative: str = "",
) -> None:
"""Initialize with the raw error and optional alternative model.

Args:
message: The original error text from the SDK.
model: The model identifier that was requested.
alternative: Suggested alternative model from the SDK, if any.
"""
super().__init__(message)
self.model = model
self.alternative = alternative
Loading
Loading