Skip to main content
Glama
README.md
# SafeHarbor MCP

**A guarded Model Context Protocol server that lets AI agents analyze healthcare claims without ever touching PHI.**

Every health plan wants analysts to ask Claude "what's our lumbar MRI denial rate by plan?" Almost none can hand an agent a connection to a claims warehouse full of names, SSNs and clinical notes. SafeHarbor is the layer in between: a zero-dependency MCP server that exposes a de-identified mart and enforces privacy **inside the database engine**, not with SQL regexes an agent can talk its way around.

```
 Claude / any MCP client
          │  JSON-RPC over stdio
          ▼
 ┌──────────────────────────── SafeHarbor ────────────────────────────┐
 │ query(sql, purpose) ──► read-only conn ──► authorizer ──► engine   │
 │                         query_only         tables,        k-supp.  │
 │                                            functions,     aggregates│
 │                                            statements     VM budget │
 │                         hash-chained audit log ◄── every call       │
 └─────────────────────────────────┬──────────────────────────────────┘
                                   ▼
                     de-identified mart (mart.db)
                                   ▲  build_mart(): pseudonymize, generalize, drop free text
                     raw warehouse (PHI) — never opened by the server
```

## Results

From [`reports/REDTEAM.md`](reports/REDTEAM.md), generated by `python -m safeharbor.redteam`:

| | |
|---|---|
| Attacks neutralized | **33 / 33** (PHI access, schema recon, sandbox escape, writes/DDL, function abuse, resource exhaustion, small-cell inference, CTE shadowing, prompt-injection follow-through) |
| Legitimate analyst queries answered | **14 / 14** (0 false positives) |
| PHI canary scan | **0 leaks**: 9,676 real identifiers from the raw warehouse checked against every byte of every allowed response |
| Median query latency | **~5 ms** |
| Known gaps | 3, run and documented in the report rather than hidden |

## How it works

**1. Physical separation, not just views.** `build_mart()` materializes de-identification views (pseudonymous `member_token`, 10-year age bands, 3-digit ZIP, service *month*, no names/SSN/DOB/address/contact/notes) into a separate file. The server only ever opens that file.

> **Why not guard views over the raw tables?** That was the first design. The red-team found the bypass: SQLite resolves a CTE before a view of the same name, so
> `WITH v_members AS (SELECT first_name, last_name, ssn FROM members) SELECT * FROM v_members`
> runs with the authorizer believing every read came from the approved view. View-level column policies can't distinguish the two. Case A32 keeps that attack in the suite as a regression test.

**2. Engine-level authorization.** SQLite's authorizer callback is consulted for every table read, column read, function call and statement type *while the query is compiled*. SafeHarbor allows reads of the three mart tables only (CTE references are recognized by their null database name; virtual tables like `pragma_table_info` and `dbstat` are not), an allow-list of functions, and `SELECT`. Everything else (PRAGMA, ATTACH, writes, DDL, transactions, `load_extension`, `group_concat`, window functions) is denied before a single row is read. The connection is also opened `mode=ro` with `PRAGMA query_only`.

**3. Small-cell suppression inside the engine.** `count`, `sum`, `total`, `avg`, `min` and `max` are replaced with implementations that return `NULL` when computed over fewer than *k* = 11 rows (the CMS cell-size threshold). Because it happens at the source, derived values are covered too: `count(*) + 0`, `sum(1)`, `HAVING count(*) < 11` probes and single-member averages all come back empty.

**4. Resource limits.** A VM-instruction budget and wall-clock deadline (via the progress handler) kill runaway recursion and cartesian joins; results are capped at 500 rows.

**5. Purpose + tamper-evident audit.** The `query` tool requires a stated purpose (HIPAA minimum-necessary). Every call (client name, purpose, SQL, normalized fingerprint, decision, reason, rows, suppressed cells, latency) is appended to a SHA-256 hash-chained JSONL log. Editing, deleting or reordering any line is detected by `python -m safeharbor.audit logs/audit.jsonl`.

**6. Prompt-injection hygiene.** One synthetic provider's `practice_name` contains an injected instruction to dump SSNs. Responses mark free-text columns as `untrusted_columns`, and the injected query is blocked regardless (A33): defense doesn't depend on the model behaving.

**Agent-friendly errors.** Denials explain themselves ("read of sqlite_master.type is not permitted; available tables: [...]", "query exceeded the compute budget; add filters or aggregate") so an agent can correct course instead of retrying blindly.

## Quick start

```bash
pip install -e ".[dev]"
python -m safeharbor.data          # builds data/raw_warehouse.db and data/mart.db (synthetic, seeded)
python -m safeharbor.redteam       # runs the suite, writes reports/REDTEAM.md
pytest -q
```

### Use it from Claude Desktop or Claude Code

```json
{
  "mcpServers": {
    "safeharbor": {
      "command": "python",
      "args": ["-m", "safeharbor.server", "--mart", "data/mart.db",
               "--policy", "policy.toml", "--audit", "logs/audit.jsonl"],
      "cwd": "/absolute/path/to/safeharbor-mcp"
    }
  }
}
```

Claude Code: `claude mcp add safeharbor -- python -m safeharbor.server --mart data/mart.db` from the repo directory.

Then ask: *"What's the denial rate by provider specialty, and how does out-of-network allowed cost compare to in-network?"*

## Policy

All rules live in [`policy.toml`](policy.toml): allowed tables, allowed functions, *k*, row cap, compute budget, untrusted text columns. Anything not listed is denied.

## Known gaps

Documented in the report and kept in the suite:

- **Differencing (G01).** Two large, allowed counts that differ by one person can be subtracted. Per-query suppression can't stop this; it needs cross-query auditing or differential privacy.
- **Quasi-identifiers (G02).** Row-level pseudonymous records are allowed for drill-down. A rare ZIP3 + sex + age-band combination can still single someone out.
- **Utility cost of fail-closed suppression (G03).** Per-member aggregates inside a CTE are nulled before the outer query sees them. The mitigation is the FDE one: precompute common member-level features into the mart (`members.claim_count`).

## Layout

```
safeharbor/
  data.py      synthetic raw warehouse (PHI) + de-identification views + build_mart()
  guard.py     authorizer, k-suppressed aggregates, budgets, row cap
  server.py    MCP stdio server (JSON-RPC 2.0, protocol 2025-06-18 / 2025-03-26 / 2024-11-05)
  audit.py     hash-chained audit log + verifier
  redteam.py   attack / utility / gap suites + PHI canary scan
policy.toml    the access policy
```

All data is synthetic and generated from a fixed seed. Python 3.11+, no runtime dependencies.

## License

MIT