Metadata-Version: 2.4
Name: mcp-ztgateway
Version: 0.5.0
Summary: Zero-trust security gateway for MCP (Model Context Protocol) tool servers — verifies a tool's declared capabilities against observed kernel syscalls, then confines it per call with mounts, seccomp and network namespaces.
License: MIT
Project-URL: Homepage, https://github.com/nabrahma/MCP_Zero-Trust_Gateway_BTP
Project-URL: Repository, https://github.com/nabrahma/MCP_Zero-Trust_Gateway_BTP
Project-URL: Issues, https://github.com/nabrahma/MCP_Zero-Trust_Gateway_BTP/issues
Keywords: mcp,model-context-protocol,security,zero-trust,gateway,llm,agent,sandbox,seccomp,landlock,prompt-injection,tool-poisoning,capability,least-privilege
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Framework :: FastAPI
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastapi>=0.115
Requires-Dist: uvicorn>=0.30
Requires-Dist: mcp[cli]>=1.0
Requires-Dist: pydantic>=2.0
Requires-Dist: PyYAML>=6.0
Requires-Dist: click>=8.0
Requires-Dist: httpx>=0.27
Requires-Dist: jsonschema>=4.20
Provides-Extra: audit
Requires-Dist: pip-audit>=2.7; extra == "audit"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Dynamic: license-file

# MCP Zero-Trust Gateway

A security gateway between an LLM agent and the MCP (Model Context Protocol) tool
servers it uses, built on one principle: **an MCP server's manifest is
attacker-controlled and therefore unverifiable, but its syscalls are not.**

Every tool is inspected before admission, profiled under `strace` in a hardened
container to see what it *actually does*, checked against what it *claimed*, and
then confined per call at the kernel level. It runs entirely on your machine —
the trust store is a local SQLite file and no telemetry leaves the host.

---

## The problem

An agent discovers tools by reading each server's self-description
(`tools/list`), then calls them with its own privileges. It treats that
description as true.

Three things follow, and each is a real attack:

- A description is **instruction-adjacent text inside the model's context**, read
  on every turn. Hide instructions there and the agent obeys them.
- A tool response **re-enters the agent's reasoning as a trusted observation**.
  Whoever controls retrieved content controls the agent's next move.
- Tools are installed like packages but run with **ambient host authority** —
  your files, your environment variables, your network.

## What this does about it

**Declare → verify → confine.** A tool publishes a capability declaration; kernel
syscall profiling verifies it before admission; per-tool resource grants enforce
it at runtime. An attacker must either *declare the attack* — and be confined to
it — or be *caught exceeding the declaration*.

Comparable systems each hold one half of that loop: `sandlock.mcp` confines
against declarations it never checks, SandScope observes flows it cannot stop,
mcp-sec-audit inspects source it never runs, ClawGuard authorises calls it cannot
contain.

---

## Results

Measured against real containers, real MCP servers and real `strace`. Every
figure is traceable to a committed row — [`eval_results.csv`](eval_results.csv),
[`evidence/`](evidence/).

| Corpus | Result | n |
|---|---|---|
| Runtime DSR (Appsecco + rug-pull + evasion) | **84.6%** (11/13) | 13 |
| ↳ Phase 1 alone (Appsecco) | 81.8% (9/11) | 11 |
| **Containment** (purpose-built corpus) | **100%** (26/26) | 26 |
| **Benign utility** retained at zero approvals | **94.3%** (33/35) | 35 |
| ↳ benign calls actually denied | **0%** | 35 |
| **Declaration verification** | **100% / 100%** (detection / fidelity) | 5 |
| MCPTox (detection only — see caveat) | **87.8%** (424/483) | 483 |
| MSB (detection only; all from L4 task scoping, L1 flagged none) | **98.2%** (1,964/2,000) | 2,000 |
| False positives (server onboarding) | **50%** (2/4) | 4 |
| Inconclusive | **0** | — |
| Unit tests | **734 passing**, 1 skipped (needs Docker) | — |

Reference points: ClawGuard 89.50% MCPTox / 94.21% MSB and 87.4% utility at zero
approvals; mcp-sec-audit 74.7% MCPTox and **0% on JavaScript servers**.

> **Read the caveats before quoting any of this.**
>
> - **MCPTox is not measured the way ClawGuard measures it** — we start no agent
>   and no container, and 346 of our 424 defences there are regex over manifest
>   text.
> - **Containment at 100% means a corpus that has stopped failing, not a solved
>   problem.** It was 91.3% until the two escapes it found were fixed. Its worth
>   is in what it caught — four confinement bugs, two scorer defects and one
>   wrong diagnosis in our own limitations document — not in its current score.
> - **The 50% false-positive rate is now deterministic, and that is an
>   improvement.** It used to read 25–50% depending on whether `mcp-fetch`'s
>   probe happened to fork during profiling; three-trial profiling means the fork
>   is always seen, so the verdict is always the same. The number got worse and
>   more truthful together.
> - Two of the three runtime phases are n = 1.
>
> All of it is spelled out in [`MD_files/Paper/`](MD_files/Paper/README.md).

**Determinism:** across five consecutive runs from a wiped trust store,
**503 of 503 scored rows produced identical verdicts**. The one metric that
varied was the false-positive rate, caused by a tool whose own probe fired
inconsistently — closed since by repeat-trial profiling.

---

## Architecture

Two phases sharing one SQLite trust store; they never call each other.

### Onboarding — `POST /register`, once per server

1. **L1 static scan** — 44 rules over names, descriptions and schemas.
   Three-valued: an attack denies, something merely suspicious quarantines.
   Only 27 of the 44 score on a manifest; the 17 behaviour rules (secret
   access, command execution, exfiltration) are recorded as signals there and
   score only on tool responses.
   A finding refuses the tool that carries it, not the whole server. Because
   honest documentation can match the same patterns ("You MUST confirm with
   the user before calling this tool"), every refusal comes with a
   `review_token`: read the finding, and if it is ordinary documentation,
   register again with the token in `acknowledged_findings`. The token is tied
   to the exact text you reviewed, and accepting it waives only the static
   verdict; profiling, verification and confinement still apply.
2. **L2 live profiling** — the server runs under `strace -f -yy -ttt` inside
   `docker run --cap-drop ALL --cap-add SYS_PTRACE --read-only`, driven over
   real MCP. `SYS_PTRACE` is the one capability kept, because `strace` cannot
   trace without it. The profiling container has network access: a server that
   installs itself at launch needs it, and a connection has to complete to be
   recorded. Registered environment values are replaced by canaries for this
   run (see below). Up to 8 tools
   are exercised with arguments synthesised from each tool's own `inputSchema`.
   `strace` is injected automatically if the image lacks it, so Node, Python and
   Go servers all profile without special-casing.
3. **Capability mapping** — syscalls → `{network, filesystem, subprocess, env}`,
   split at the MCP `initialize` boundary so package-manager bootstrap is not
   blamed on the tool.
4. **L3 comparator + declaration verification** — observed vs declared. A tool
   that exceeds its own published grant is denied, and no renaming evades it.
5. **Seccomp compilation** — the capability *verdict* (not the raw trace) is
   compiled into a syscall deny-list by subtraction, then **smoke-tested**: the
   tool must complete a real MCP `initialize` under it, or the gateway degrades a
   tier and says so.

### Runtime — `POST /tool_call`, every call

Inbound DLP → trust-store lookup → **L4 policy** (three-valued: allow / deny /
*hold for approval*) → **response-provenance gate** → fresh confined container →
**manifest re-attestation** → outbound DLP → log.

Each call gets only the paths its verified grant names, only the environment
variables it declared, `--network none` unless it declared hosts, and its own
seccomp profile.

### What is novel here

- **Response-provenance gating** — asks *where an argument came from* rather than
  what it means, so it is paraphrase- and encoding-proof.
- **Continuous manifest attestation** — catches a description that changes
  *after* approval (CVE-2025-54136 class), separating "changed" from "changed
  into an attack".
- **Declaration verification as a measurable axis** — comparable systems score 0%
  on it by construction.

---

## Scope: process ownership

Kernel-level protection requires the gateway to be **the process that spawns the
tool**.

- **Supported** — any MCP server the gateway launches as a child process:
  effectively all `stdio` tools (`npx`, `python`, compiled binaries).
- **Unsupported** — anything running remotely, or launched by another
  application. A native desktop app exposing SSE on `127.0.0.1` cannot be
  profiled, because we did not spawn it.

This is the inverse of protocol-layer defences like ClawGuard, which inspect
JSON-RPC and work on servers they never own but cannot constrain execution. The
two are complementary, with inverse blind spots.

---

## Install

Python 3.11+ and Docker Desktop, running. Docker is what makes L2 profiling and
runtime confinement possible; without it you get a static scan and nothing else.
You do **not** need to install `strace` — it is injected into the tool's image
when missing.

```bash
mkdir ztg-workspace && cd ztg-workspace     # the gateway writes its DB and
python -m venv .venv                        # profiles relative to this directory
.venv\Scripts\Activate.ps1                  # Windows  (source .venv/bin/activate elsewhere)
pip install mcp-ztgateway

mcp-ztgateway doctor                        # check the machine
mcp-ztgateway serve                         # http://127.0.0.1:8000
```

`serve` is a subcommand — `mcp-ztgateway --port 8000` on its own is an error. It
binds localhost with no API key by default; set `GATEWAY_API_KEY` before binding
anything else.

From source:

```bash
git clone https://github.com/nabrahma/MCP_Zero-Trust_Gateway_BTP.git
cd MCP_Zero-Trust_Gateway_BTP
python -m venv venv && .\venv\Scripts\Activate.ps1
pip install -e ".[dev]"
mcp-ztgateway serve
```

**New here? [MD_files/GETTING_STARTED.md](MD_files/GETTING_STARTED.md)** walks a
blank machine through to a confined tool call, with the real outputs at each
step and a troubleshooting table.

---

## Usage

### Onboard a tool

```json
POST /register
{
  "name": "get_current_time",
  "command": ["docker","run","-i","--rm","mcp/time"],
  "image": "mcp/time",
  "test_tool": "get_current_time",
  "test_args": {"timezone": "Asia/Kolkata"}
}
```

`mcp/time` is the reference time server from Docker's MCP catalog
(`docker pull mcp/time`). `command` is how you would start the server yourself;
the gateway reads the image's own ENTRYPOINT for profiling. A full walkthrough
with real output is in [GETTING_STARTED](MD_files/GETTING_STARTED.md).

Returns a verdict — `allow`, `allow_with_warning`, `needs_declaration`,
`quarantine` or `deny` — plus the observed capabilities, the compiled seccomp
profile, and a **paste-ready declaration** drafted from what the tool was
actually seen doing.

`needs_declaration` means "we could not classify this tool, so we have no basis
to judge it". It is non-runnable like quarantine, but it is *not* an accusation.

### Declare capabilities (recommended)

```json
"annotations": {
  "mcpzt:fs_read":         ["/workspace"],
  "mcpzt:net_allow_hosts": ["api.example.com"],
  "mcpzt:subprocess":      ["node"],
  "mcpzt:env":             ["WORKSPACE"]
}
```

Standard MCP hints (`readOnlyHint`, `openWorldHint`) are **never treated as
permission**; honouring them that way would let a malicious server grant itself
anything. They are read as claims to check instead: `readOnlyHint: true`
followed by an observed write, or `openWorldHint: false` followed by an observed
connection, is a declaration violation in the server's own words. Only those
two negative claims narrow anything (see `scanner/declaration_inference.py`).

> **Since 0.4.0 — a tool that declares no `mcpzt:env` grant receives no
> environment variables at all.** It used to inherit everything the operator
> registered, which made declaring nothing the cheapest way to be trusted with
> everything; the containment corpus recovered `DB_PASSWORD` and `API_TOKEN`
> that way. If a tool stops finding its configuration after upgrading, add the
> variables to `mcpzt:env` — or set `MCPZT_ENV_PASSTHROUGH=1` for an unmodified
> third-party server that cannot publish annotations.

> **Registered environment values are not given to a server while it is being
> profiled.** Profiling runs a server nobody has judged yet, with network
> access, so each registered variable is replaced by a canary token of the same
> name; forwarding one is recorded as evidence. A server that cannot start
> without a working credential will fail onboarding with a message saying so —
> set `MCPZT_PROFILE_REAL_ENV=1` to profile it with the real values.

### Scope a session to a task (optional)

```json
POST /session   { "session_id": "s1", "objective": "Read ./report.md and summarise it" }
```

Calls outside that objective are **held for approval**, not denied. Approve with
`POST /approvals/{id}`; the approval authorises the exact arguments a human saw
and is consumed on use.

### Other endpoints

`/tools` · `/logs` · `/verdicts` · `/profiles` · `/enforcement` · `/egress` ·
`/confinement` · `/approvals`

### Connecting an agent

`mcp-ztgateway stdio` is an MCP server that MCP clients (Claude Desktop, Cursor,
VS Code) can launch. It shows the client the tools that passed onboarding, with
their approved descriptions, and forwards every call to `POST /tool_call`, so
every layer above still runs. A refused call comes back as a tool error with the
gateway's reason. Start the gateway with `serve`, then add to the client's config:

```json
{ "mcpServers": { "zero-trust-gateway": { "command": "mcp-ztgateway", "args": ["stdio"] } } }
```

Setup for each client is in [GETTING_STARTED §7](MD_files/GETTING_STARTED.md#7-connecting-an-agent).
Code can also call `POST /tool_call` directly, which is what the evaluation
harness does.

### Docker Compose

```bash
docker-compose up --build     # gateway :8000, dashboard :5173
```

> Live profiling needs the host Docker daemon. The compose image deliberately has
> **no Docker socket**, so run the gateway on the host for evaluation.

---

## Limitations

Stated properly in
[`MD_files/Paper/LIMITATIONS.md`](MD_files/Paper/LIMITATIONS.md). The ones that
matter most:

- **Response-content attacks are not covered.** A tool that does its advertised
  job and returns *fabricated data* crosses no boundary here. This is the open
  frontier, and every comparable system shares it.
- **Dynamic profiling is evadable and non-deterministic.** A dormant payload is
  invisible at onboarding; containment is what makes that survivable.
- **Corpora are small.** No statistical significance is claimed anywhere.

---

## Documentation

Start at [`MD_files/README.md`](MD_files/README.md).

| Want to… | Read |
|---|---|
| Join the project | [TEAMMATE_HANDOVER.md](MD_files/Planning_and_Management/TEAMMATE_HANDOVER.md) |
| Write the paper | [`MD_files/Paper/`](MD_files/Paper/README.md) |
| Check a number | [Paper/RESULTS.md](MD_files/Paper/RESULTS.md) |
| Review the design | [ARCHITECTURE.md](MD_files/Architecture_and_Design/ARCHITECTURE.md) |
| Reproduce results | [Paper/REPRODUCIBILITY.md](MD_files/Paper/REPRODUCIBILITY.md) |

**How to read our numbers.** Every defence names the layer that produced it.
Crashes and timeouts are `INCONCLUSIVE` and stay in the denominator — never
counted as defences. DSR is never quoted without the false-positive rate. All raw
data is committed, so if a number looks too good, open the row and check it.

That discipline exists because this project once reported a **100% Defence
Success Rate that was an artefact of two measurement bugs cancelling out** — a
seccomp compiler that made every container fail to boot, and a harness that
scored the crashes as defences. The post-mortem is
[GROUND_TRUTH_AUDIT.md](MD_files/Audits_and_Evaluations/GROUND_TRUTH_AUDIT.md).

---

## Tech stack

Python · FastAPI · official MCP SDK (stdio) · Docker · `strace` · seccomp ·
Landlock (opt-in) · SQLite · React dashboard

## License

MIT — see [LICENSE](LICENSE).
