BashaarJavaid/PortunusMCP
GitHub: BashaarJavaid/PortunusMCP
PortunusMCP 是一个零信任安全网关代理,通过身份作用域工具裁剪、schema 偏移检测、风险评分和防篡改审计追踪来填补 MCP 协议在授权与可审计性方面的安全缺口。
Stars: 0 | Forks: 0
# PortunusMCP
**一个为 Model Context Protocol (MCP) 强制执行策略的网关代理** —— 提供基于身份作用域的工具可见性、schema 偏移(“rug pull”)检测、单次调用风险评分以及防篡改的签名审计追踪,且无需对上游 MCP server 进行任何更改。
[](https://github.com/BashaarJavaID/PortunusMCP/actions/workflows/ci.yml)
-brightgreen)

[](./LICENSE)

*低权限身份只能看到允许其使用的工具。上游 server 在会话中途“rug-pull”(恶意篡改)了自己的 schema。网关将此偏移判定为 Critical,拦截了该调用,并将其挂起等待人工重新审批。对字节完全一致的重放请求予以拒绝。最后,针对刚刚发生的流量模拟了一份策略草案。*
## 为什么需要
MCP 定义了 LLM client 与工具 server 之间的 JSON-RPC 2.0 传输协议,但有意将授权、可审计性和完整性排除在范围之外——它假定部署的组织会自行构建该层。但在实践中几乎没人这么做,从而留下了三个具体的缺口:
1. **缺乏基于身份作用域的工具可见性。** 任何能够连接到 server 的 client 都会获得该 server 完整的 `tools/list`。原生并不存在“该用户只应看到一部分工具”的机制。
2. **Rug pulls(恶意篡改)。** Server 可以在人工于先前会话中批准某个工具的 schema *之后*对其进行更改,却没有任何机制来检测这种偏移。
3. **缺乏审计追踪。** 没有任何东西记录哪个身份、在哪个策略下、使用哪些参数调用了哪个工具,并能以某种形式让你日后证明它未被篡改过。
PortunusMCP 介于两者之间,填补了这三个缺口。
### Client 兼容性
**双方均无需任何更改。** 上游 server 按原样进行代理,原生的 Claude Desktop、Cursor 或 SDK `ClientSession` 在默认的 `bearer` 认证模式下无需修改即可运行 —— RBAC、ABAC、偏移拦截、风险评分、参数验证以及签名审计追踪在每次调用中都会得到充分执行(集成测试套件端到端运行了一个未打补丁的 SDK client)。认证姿态是基于单个身份的([`ROADMAP.md`](./ROADMAP.md) 第 34 项):能够采用小型签名 client 的身份可选择开启 `signed` 模式,在该模式下,请求会携带一个非机密密钥 ID 以及基于该调用的 HMAC —— 传输过程中完全没有凭证,这使得重放防护得以真正实现(被截获的请求无法使用新的 nonce 重新签名)。这种权衡是真实的:`bearer` = 零 client 改动,密钥随请求传输;`signed` = 需自定义 client,防截获。
## 架构
网关内的每个框都是 `services/gateway/` 中同名的模块;带有编号的阶段是 `ARCHITECTURE.md` §4.2 顺序中的决策流水线。时序图、部署图和数据流图位于 §4.4–§4.7。
```
graph TD
Client["MCP Client"] -->|"Streamable HTTP"| Interceptor
subgraph Gateway["PortunusMCP Gateway process"]
Interceptor["JSON-RPC Interceptor + Session Manager"]
Interceptor --> Replay["1 Replay Guard"]
Replay --> Auth["2 Auth / Identity"]
Auth --> Policy["3+4 Policy Engine: RBAC + ABAC conditions + versioning"]
Policy --> Drift["5 Drift Detector"]
Drift --> Risk["6 Risk Engine"]
Risk --> Validator["7 Param Validator"]
Validator --> UpClient["Upstream Client"]
Interceptor --> SchemaCache["Schema Cache + Pruner (tools/list)"]
Interceptor --> AuditW["Audit Log Writer (hash-chained, ECDSA-signed)"]
Approvals["Approvals lifecycle (admin API)"]
Explainer["Decision Explainer (admin API)"]
Simulator["Policy Simulator (admin API)"]
end
UpClient --> Srv["Upstream MCP Server (stdio subprocess, one per session)"]
Replay --> Redis[("Redis: nonces, schema cache, risk counters, session TTL")]
Risk --> Redis
SchemaCache --> Redis
Policy --> PG[("Postgres: audit_log, policy_versions, tool_baselines, approvals")]
Drift --> PG
AuditW --> PG
Approvals --> PG
Explainer --> PG
Simulator --> PG
Policy --> Rev["policies/revisions/ snapshots (rw submount)"]
Simulator --> Rev
Verifier["audit_verifier sidecar (separate process, read-only chain walk)"] --> PG
```
**多个上游在策略的 `servers:` 块中注册**(第 35 项):`server_id → stdio command`,并与其余策略一起进行版本控制和回滚。Client 连接到 `/mcp/`;一个会话绑定到一个上游,在连接时选定。RBAC 授权、偏移基线、schema 缓存、风险计数器和审批全部以真实的 `server_id` 为键,因此两个 server 上同名的工具被视为两个不同的工具。
## 威胁模型(摘要)
包含整个模型所依赖假设的完整版本位于 [`THREAT_MODEL.md`](./THREAT_MODEL.md)。它明确指出了*未*涵盖的内容——一个诚实的范围界限远比隐式声称涵盖一切更有价值。
| 威胁 | 是否受保护? | 如何保护 / 为何不保护 |
|---|---|---|
| 已知身份对工具的未授权访问 | 是 | RBAC + ABAC 策略解析 |
| 恶意 / 进行“rug-pulling”的 MCP server(schema 突变) | 是 | Drift Detector 对突变进行分类;High/Critical 级别在重新审批前于 `tools/call` 处被拦截 |
| *已授权*身份在上下文中存在风险的调用 | 是 | Risk Engine —— 根据评分区间执行 挑战 / 人工审批 / 拒绝 |
| 篡改审计日志 | 是 | Hash chain + 每行 ECDSA 签名;由仅持有公钥的 sidecar 独立验证 |
| 截获请求的重放 | **`signed` 模式为是 / `bearer` 模式为部分** | `signed` 请求不携带凭证:字节完全相同的重放会被去重(`DENY_REPLAY`),且全新的 nonce 无法被重新签名(边缘层返回 401)。`bearer` 仅保留机会性去重 —— API key 包含在被截获的请求中 |
| Tool Poisoning(描述中的对抗性文本) | **部分** | 审批后更改的描述在重新审批前会被拦截(默认为 High,第 36a 项);首次接触的基线会进行启发式扫描 —— 命中将被审计(`BASELINE_FLAGGED`)并提高后续所有调用的风险,但标记从不会拦截调用,且新颖的措辞能绕过模式列表。描述依然会原封不动地传达给 LLM —— 部分防护即为其上限 |
| 通过工具*结果*进行的 prompt injection | 部分 | 协议层网关可以记录和限速,但无法对结果内容进行语义评估 —— 这是 client/agent-framework 的责任 |
| 被盗的 API key | 部分 | 行为风险因素可减小爆炸半径;仅凭密钥本身无法与其持有者区分开来。`signed` 身份的密钥绝不会出现在传输过程中 —— 窃取它意味着主机环境已被攻破 |
| 网关主机被攻破 | 否 | 攻击者拥有签名密钥 —— 这是基础设施加固问题,而非应用问题 |
| 内部管理员滥用合法访问权限 | 否 | 只能做到事后可追溯且防篡改,而非事前预防;双人激活已设计,但尚未构建 |
## 运行演示
```
python scripts/generate_signing_key.py # once: audit signing keypair (gateway won't start without it)
python scripts/run_demo.py # resets demo state, mints keys, writes policies/demo-policy.yaml, waits
```
```
# 在另一个终端中(恶意 upstream 命令存在于 demo policy 的 servers: block 中):
POLICY_FILE=policies/demo-policy.yaml \
docker compose up -d --build
# 当 driver 提示时 —— rug pull,故意显示在屏幕上:
curl -X POST localhost:9800/_admin/apply_mutation
# 当它再次提示时 —— 热加载收紧的 v2 policy 以完成 simulation finale:
docker kill -s HUP portunusmcp-gateway-1
```
驱动程序以 `developer` 身份连接 —— 这是一个原生的 MCP client,任何地方都没有自定义的 `_meta`(只能看到 `send_email` / `read_inbox`;破坏性的 `delete_mailbox` 是*缺失的*,而不是被标记为禁用),随后以 `ops-admin` 身份连接(能看到全部三个工具)。它发起一次成功的调用,等待操作员的 mutation curl,然后展示被判定为 Critical 并被拦截(`DENY_DRIFT`)的偏移,管理员重新审批,相同的调用针对新 schema 成功执行,接着是 `signed` ci-agent 被截获的请求以字节完全一致的方式被重放(`DENY_REPLAY`),以及使用伪造的新 nonce 进行重放(HTTP 401 —— 截获的信息中不包含可用于重新签名的凭证),最后是一个策略模拟,针对 v2 草案重放演示本身的流量(`would_now_deny: 3`),然后打印 hash-chained 审计回执。
全部七个环节都是实时的 —— 没有任何脚本或造假。只有当操作员实际调用该 endpoint 时,才会触发突变,因此对抗性事件在镜头前清晰可见,而不是在屏幕外通过计时器发生。
之后,普通的 `docker compose up` 会故意拒绝启动:演示的策略 v1 在记录中具有不同的内容,fail-closed(故障即关闭)激活检查捕获了这一点。启动错误指明了解决方法 —— `docker compose run --rm gateway python scripts/reset_dev_state.py --yes`(仅限开发环境:清除本地审计链和演示状态,从不清除检查本身)。
**开发环境设置:** `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"`,然后运行 `.venv/bin/pytest`。完整命令列表见 [`CLAUDE.md`](./CLAUDE.md)。
## 性能
以下数据是实测值而非估算值,测试时间为 **2026-07-10**,提交记录为 **`902341f`**,且完整的 §4.2 流水线均处于激活状态(重放 → 认证 → RBAC + ABAC → 偏移检测 → 包含全部八个因素的风险评分 → 参数验证 → 签名审计写入)。测试方法、硬件和复现步骤详见:[`ARCHITECTURE.md` §9](./ARCHITECTURE.md#9-performance-benchmarks)。
| 场景 | 直接调用 | 通过网关 | 开销 |
|---|---|---|---|
| 单次调用,已缓存 schema | 1.38 / 1.33 / 1.56 / 1.94 ms | 13.47 / 12.95 / 16.12 / 24.46 ms | 12.09 / 11.62 / 14.56 / 22.51 ms |
| 单次调用,冷 schema 缓存 | 1.38 / 1.33 / 1.56 / 1.94 ms | 16.62 / 16.17 / 19.51 / 24.71 ms | — |
| 10 个并发会话 (p95) | — | 160.20 ms | — |
| 50 个并发会话 (p95) | — | 565.46 ms | — |
| 100 个并发会话 (p95) | — | 1228.23 ms | — |
| `tools/list` 负载(已裁剪身份) | 1506 B(未裁剪) | 797 B | **减少了 47.1%** |
延迟数值为 平均值 / p50 / p95 / p99。高并发下的 p95 主要受制于同步的 fail-closed 审计写入在 Postgres 连接池上的竞争,以及每个会话对应一个 stdio 子进程 —— 这两者都是已知的性能上限,已在 `ARCHITECTURE.md` §10 中讨论。
## 技术栈
| 层级 | 选择 | 原因 |
|---|---|---|
| Runtime | Python 3.12, FastAPI + Starlette, 全面采用异步 | 第一方 MCP SDK;对于一个几乎完全处于 I/O 等待状态的代理来说是原生异步的 |
| MCP 处理 | `mcp` 官方 Python SDK | 无需手动编写 JSON-RPC 帧格式 —— 而是直接在会话层进行拦截 |
| 存储 | PostgreSQL 16(审计链、基线、审批、策略版本)+ Redis 7(nonce、schema 缓存、风险计数器) | 关系完整性对于 hash chain 至关重要;带有 TTL 的需求全部交由 Redis 处理 |
| 策略 | YAML + Pydantic,辅以手动编写的 ABAC 表达式评估器(`ast.parse` + 节点白名单,不使用 `eval`) | 可通过 Git diff 对比,并在加载时进行验证。故意设计为非图灵完备 —— 无循环、无递归、无代码执行(关于为何不使用 OPA 参见 [ADR-004](./docs/adr/ADR-004-no-opa-for-v1.md)) |
| 风险 | 固定的加权因子列表 + 行为 Redis 计数器 —— **按设计不使用 ML** | 操作员无法解释的安全决策,就是他们无法信任的安全决策 |
| 加密 | SHA-256 hash chain + ECDSA P-256 每行签名 | 仅靠链条本身,任何拥有数据库写权限的人都能重新生成;但签名不行 |
| 运维 | Docker Compose, Prometheus + Grafana(可选启用配置), structlog JSON 日志, GitHub Actions(在 80% 覆盖率门限下执行 ruff / mypy strict / pytest) | |
## 文档
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) —— 决策流水线、每个组件的深入剖析、故障模式、可观测性、基准测试、可扩展性、测试、部署
- [`THREAT_MODEL.md`](./THREAT_MODEL.md) —— 受保护的内容、不受保护的内容以及背后的假设
- [`SECURITY.md`](./SECURITY.md) —— 漏洞披露
- [`docs/adr/`](./docs/adr/) —— 每个重大决策对应一个文件,包括为何在 v1 版本中各自拒绝使用 Envoy、OPA、Kong、NGINX、sidecars 和 client-SDK middleware
- [`ROADMAP.md`](./ROADMAP.md) —— 以动态核对清单呈现的构建顺序
## 路线图
阶段 1–3(核心网关 → 强化 → 风险与策略功能)已完成;阶段 4 是生产基础设施和最终定型工作。
**阶段 5 是将这个可运行的演示转化为 AppSec 团队能够真正采用的产品所需的工作**,它源于对已完成 v1 版本的对抗性自我审查:三个缺陷修复(过滤器绕过、重复的风险阈值、无上限的风险衰减),然后是针对单个身份的认证姿态,既恢复了原生 client 的兼容性,又通过将密钥移出传输通道真正实现了重放防护;此外还包括真正的多服务器注册表、工具描述完整性以及真正的 step-up auth(升级认证)。
[`ROADMAP.md`](./ROADMAP.md) 中的每一项都说明了证明其完成的检查标准,以及它所升级的威胁模型行 —— **当该行能够被如实地重写时,一项才算真正完成,而不是在代码合并时。**
## 许可证
MIT —— 详见 [`LICENSE`](./LICENSE)。
标签:API安全, DLL 劫持, JSONLines, JSON输出, MCP网关, Python, Streamlit, 大语言模型, 审计日志, 无后门, 版权保护, 访问控制, 零信任