Skip to main content
Glama
iabdullah215

pathhound-mcp

by iabdullah215
README.md
# pathhound-mcp

An **MCP (Model Context Protocol) server** that lets an LLM reason over an Active
Directory attack graph **already collected by BloodHound** — shortest paths to
Tier 0, blast radius from a principal, the highest-leverage choke points to
remediate, and defender-facing remediation text — with every action scoped to an
authorized engagement and logged.

> **Authorized penetration-testing use only.** This server never touches a live
> domain, never collects data, and never runs attacks. Collection is done
> separately (SharpHound / `bloodhound-python`); this tool only *reads and
> reasons over* the resulting graph in Neo4j. See [SECURITY.md](SECURITY.md) for
> the full intended-use boundary.

## What it does

- **Turns a collected graph into explainable answers.** Ask "what are the
  shortest paths to Domain Admins," "if I own this account what becomes
  reachable," or "which edges should we fix first" and get structured results.
- **Read-only and scoped by design.** It refuses to start without an engagement
  scope, refuses to query domains outside that scope, rejects write/destructive
  Cypher, and appends every call to an audit log.
- **Defender-facing output.** `explain_edge` and `remediation_for_path` produce
  report-ready remediation, not exploit commands.

### Tools

| Tool | What it returns |
| --- | --- |
| `list_domains()` | Domains/forests in the graph, with node counts and an in-scope flag. |
| `run_cypher(query, params)` | Rows from a **read-only** Cypher query. Write clauses are rejected unless `allow_writes` is set; destructive/external ops are always refused. Results are row-capped with a truncation flag. |
| `shortest_paths_to_tier0(start, max_paths)` | Shortest attack paths to Tier 0 / high-value targets — from `start` (an in-scope principal) or from every `owned` principal. Each path is an ordered list of `start —[edge]→ end` hops. |
| `reachable_from(principal, max_hops)` | Blast radius from a principal, summarized by node type, highlighting any Tier 0 reachability. |
| `find_choke_points(top_n)` | The edges appearing in the most distinct shortest paths to Tier 0, each with the number of paths removing it would break. |
| `explain_edge(edge_type)` | Plain-language reason a BloodHound edge (e.g. `GenericAll`, `AddKeyCredentialLink`, `DCSync`) is abusable, the technique name, a defender remediation, and reference pointers. |
| `remediation_for_path(path_id_or_cypher)` | For a path (given as a read-only Cypher query returning a path `p`), the specific ACE / delegation / membership change that breaks each hop, phrased for a report. |
| `engagement_summary()` | Headline counts for reporting: principals, owned, high-value, distinct paths to Tier 0, and the top choke points. |

### Resources

| Resource | Contents |
| --- | --- |
| `pathhound://engagement-scope` | The active scope (engagement id, authorization reference, allowed domains, write flag) and the Neo4j target with the password masked. |
| `pathhound://cypher-library` | A curated, versioned set of named read-only Cypher queries (Kerberoastable, unconstrained delegation, AS-REP roastable, paths to Domain Admins, ACL abuse from a principal, …), each validated read-only on load. |

### Optional write tools (off by default)

Two tools — `mark_owned(principal, confirm)` and `set_high_value(principal,
confirm)` — set BloodHound's owned / Tier 0 markers (and nothing else) so the
path tools treat those nodes accordingly. They only exist when `allow_writes:
true` is set in `scope.yaml`; each call also requires an explicit `confirm=true`,
an in-scope principal, and is audited. They never delete or drop. Leave
`allow_writes: false` (the default) for a strictly read-only deployment.

## Requirements

- Python 3.11+
- A **BloodHound CE** Neo4j instance reachable over Bolt (the graph is collected
  and ingested separately — see the quickstart below)

## Install

```bash
# With uv (preferred):
uv venv && uv pip install -e ".[dev]"

# Or with pip:
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt && pip install -e .
```

## Configure

Two pieces of configuration: the **engagement scope** (a file) and the **Neo4j
connection** (environment variables — credentials are never hardcoded).

**1. Engagement scope** — copy the example and edit it:

```bash
cp scope.example.yaml scope.yaml
```

```yaml
# scope.yaml
engagement_id: "ENG-0001"                      # names the audit log file
authorization_ref: "SoW-2026-ACME-001 / JIRA-1234"
allowed_domains:                               # only these may be queried
  - "CORP.LOCAL"
  - "CHILD.CORP.LOCAL"
allow_writes: false                            # keep false unless you need the write-flag tools
max_rows: 500                                  # run_cypher row cap
```

The server **refuses to start** if `scope.yaml` is missing or `allowed_domains`
is empty — this is intentional; there are no unscoped queries. Domain names are
matched case-insensitively against BloodHound's uppercase FQDNs.

**2. Neo4j connection** — via environment variables (or a `.env`; see
`.env.example`):

```bash
export NEO4J_URI="bolt://127.0.0.1:7687"
export NEO4J_USER="neo4j"
export NEO4J_PASSWORD="…"
export NEO4J_DATABASE="neo4j"        # optional, defaults to neo4j
# export PATHHOUND_SCOPE="/abs/path/scope.yaml"   # optional; defaults to ./scope.yaml
```

## Run

As a standalone stdio MCP server:

```bash
pathhound-mcp          # or:  python -m pathhound_mcp.server
```

### Register with an MCP client

Point your client at the console script (use the absolute path from your venv,
e.g. `/path/to/pathhound-mcp/.venv/bin/pathhound-mcp`) and pass the connection +
scope through its environment. Example (Claude Desktop `claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "pathhound": {
      "command": "/path/to/pathhound-mcp/.venv/bin/pathhound-mcp",
      "env": {
        "NEO4J_URI": "bolt://127.0.0.1:7687",
        "NEO4J_USER": "neo4j",
        "NEO4J_PASSWORD": "…",
        "PATHHOUND_SCOPE": "/path/to/pathhound-mcp/scope.yaml"
      }
    }
  }
}
```

### Example questions to ask the LLM

Once connected, an operator can ask things like:

- *"List the domains in the graph and how big each is."* → `list_domains`
- *"Which accounts are Kerberoastable?"* → `run_cypher` with the `kerberoastable_users` library query
- *"Show the shortest attack paths to Tier 0."* → `shortest_paths_to_tier0`
- *"If I compromise `alice@corp.local`, what becomes reachable?"* → `reachable_from`
- *"What are the top 10 choke points we should fix first?"* → `find_choke_points`
- *"Why is `AddKeyCredentialLink` dangerous, and how do we fix it?"* → `explain_edge`
- *"Give me remediation steps for this attack path."* → `remediation_for_path`
- *"Summarize the engagement for the report."* → `engagement_summary`

## Quickstart: collect into BloodHound, then point this at Neo4j

This server only reads an **already-collected** graph; collection happens
separately and outside this tool.

1. **Collect** the domain with SharpHound or `bloodhound-python` during your
   authorized engagement. You get a set of JSON files (or a zip) — from a lab
   like [GOAD](https://github.com/Orange-Cyberdefense/GOAD) (GOAD-Light ~16 GB,
   full GOAD ~32 GB) or a real engagement.

2. **Stand up BloodHound CE and ingest the data.** The official compose stack
   runs Neo4j (Bolt on `127.0.0.1:7687`), Postgres, and the BloodHound UI on
   `:8080`:

   ```bash
   docker compose -f /path/to/docker-compose.yml up -d
   # open http://localhost:8080, log in, and upload the collection zip
   # (Administration → File Ingest). Wait for ingest + post-processing.
   ```

   If the Neo4j volume is already ingested, you can start just the graph:
   `docker compose -f /path/to/docker-compose.yml up -d graph-db`.

3. **Point pathhound-mcp at that Neo4j** (see [Configure](#configure)) and run
   it. Your `allowed_domains` in `scope.yaml` should match the domains in the
   collection (e.g. `CORP.LOCAL`).

### BloodHound schema compatibility

Newer BloodHound CE tags **Tier 0 with the `Tag_Tier_Zero` node label** and
**owned principals with `Tag_Owned`**, while older versions used the `highvalue`
/ `owned` properties (and CE also sets `system_tags: admin_tier_0`). pathhound
recognizes all of these, so Tier 0 / owned reasoning works across versions.

## Tests

```bash
pytest        # unit tests — guardrails, scope, audit, Cypher guard, tool logic
```

The suite is fully **database-free**: the read-only guardrail, scope enforcement,
audit logging, row-capping, and path/reporting logic are all exercised against
stubs, so `pytest` runs in a fraction of a second with no Neo4j. (The tools have
also been validated end-to-end against a real BloodHound CE graph.)

## Project layout

```
src/pathhound_mcp/
├── server.py          # FastMCP app: tool/resource registration + startup guard
├── config.py          # loads/validates scope.yaml (pydantic); Neo4j from env
├── guardrails.py      # read-only Cypher guard + scope enforcement
├── audit.py           # append-only JSONL audit logger (@audited decorator)
├── graph.py           # Neo4j connection + read helpers (the only DB access)
├── schema.py          # attack-edge set + Tier 0 / owned predicates
├── models.py          # pydantic result models
├── cypher_library.json / edge_library.json   # curated read-only queries + edge KB
└── tools/             # inventory, query, paths, reporting, edges
```

`audit/`, `scope.yaml`, and `.env` are gitignored — treat the audit log and the
collected graph as engagement-sensitive.

## Safety model

See [SECURITY.md](SECURITY.md) for the intended-use boundary and the guarantees:
scope-at-startup, read-only by default, scope enforcement, append-only audit log,
and no live-domain capability.

## License

MIT — see [LICENSE](LICENSE).