--- title: "fix: P0 安全与多实例一致性加固" date: 2026-06-26 type: fix status: planned origin: 代码走查报告(portal-platform-evolution 合并后审查) --- # fix: P0 安全与多实例一致性加固 ## Summary 修复代码走查发现的 4 项高优先级问题,分两组:(1) 安全 fail-open 默认值 — Gateway KB cache fail-closed + MCP dangerous-tool 黑名单过滤;(2) 多实例部署一致性 — SecretsStore 与 Channels 全局状态迁移到 Redis,消除多 worker 下的安全与状态共享缺口。 ## Problem Frame `portal-platform-evolution` 合并引入了多端渠道(U10-U12)、MCP 协议(U13/U16)、LiteLLM 缓存(U17)等特性。代码走查发现两类系统性风险: 1. **安全 fail-open**:Gateway 在 KB settings 读取异常时默认启用缓存(可能泄漏禁用缓存的 KB 数据);MCP 端点暴露所有工具(含 ShellTool 等危险工具),绕过 chat 流程的 confirmation 机制。 2. **多实例不一致**:SecretsStore 和 Channels 路由使用模块级内存字典存储凭证、nonce、限流、backpressure 状态,多 worker 部署下失效,与 README 宣称的 K8s 多实例部署目标冲突。 ## Requirements - **R1**:Gateway KB cache 在 settings 读取失败时必须 fail-closed(禁用缓存),不得 fail-open - **R2**:MCP `/tools/call` 和 `/tools/list`(含 JSON-RPC 端点)必须过滤危险工具,禁止绕过 chat confirmation 流程 - **R3**:SecretsStore 加密凭证必须存储在 Redis,多 worker 共享,保留 AES-256-GCM 加密层 - **R4**:Channels webhook nonce dedup、rate limit、backpressure、渠道配置必须存储在 Redis,多 worker 一致 - **R5**:所有改动不破坏现有单进程行为(Redis 不可用时降级到内存,需显式 log warning) - **R6**:每个修复单元包含可运行的验证测试 ## Key Technical Decisions ### KTD1: H4 危险工具过滤采用黑名单 **决策**:使用 `_MCP_BLOCKED_TOOLS: frozenset[str]` 黑名单,而非在 Tool 基类增加 `requires_confirmation` 元数据字段。 **理由**:黑名单改动最小(ponytail 原则),仅影响 mcp/server.py 一个文件。元数据方案需修改 Tool ABC + 所有工具子类 + ToolRegistry,改动面过大。黑名单天花板已在代码注释标注(`ponytail: 黑名单需手动维护,新增危险工具需同步更新`)。 ### KTD2: H1/H2 Redis 连接复用 app.state.working_redis_client **决策**:复用 `app.py` lifespan 已创建的 `app.state.working_redis_client`(`aioredis.from_url(redis_url, decode_responses=True)`),通过 FastAPI `Depends` 或 `request.app.state` 注入到 channels 路由和 SecretsStore。 **理由**:避免新建 Redis 连接池(资源浪费),与 WorkingMemory、TaskManager 等现有子系统共享同一连接。`decode_responses=True` 与 RedisBus 模式一致。 ### KTD3: 降级策略 — Redis 不可用时 fail-closed **决策**:Redis 连接失败时,webhook 端点返回 503(而非降级到内存),secrets 读取返回 None(而非空字典)。 **理由**:安全优先。降级到内存会导致多 worker 间状态不一致,nonce dedup 失效可能引发重放攻击。fail-closed 符合 AGENTS.md 安全约定。 ## Implementation Units ### U1. Gateway KB cache fail-closed **Goal**: KB settings 读取异常时禁用缓存,而非默认启用。 **Requirements**: R1 **Dependencies**: 无 **Files**: - `src/agentkit/llm/gateway.py`(修改第 137-146 行) - `tests/unit/llm/test_gateway_cache_failclosed.py`(新建) **Approach**: 将 `kb_caching_disabled` 默认值从 `False` 改为 `True`(fail-closed),仅在 settings 成功读取且 `caching_disabled=False` 时才启用缓存。读取异常或 settings 为 None 时保持 `True`。 **Patterns to follow**: 现有 `should_cache` 已有 `per_user_namespace + user_id=None` 防护逻辑,本单元补齐 KB settings 读取层的防护。 **Test scenarios**: - **Happy path**: settings 正常读取 `caching_disabled=False` → 缓存启用,`should_cache` 返回 True - **Happy path**: settings 正常读取 `caching_disabled=True` → 缓存禁用,`should_cache` 返回 False - **Error path**: `get_settings_store().get_settings()` 抛异常 → `kb_caching_disabled=True`,`should_cache` 返回 False(fail-closed) - **Edge case**: settings 返回 None(KB 不存在)→ `kb_caching_disabled=True` - **Edge case**: `kb_id=None`(非 RAG 请求)→ 不查 settings,缓存正常启用 **Verification**: `pytest tests/unit/llm/test_gateway_cache_failclosed.py -v` 全绿;mock settings store 抛异常时断言 `should_cache` 返回 False。 --- ### U2. MCP dangerous-tool 黑名单过滤 **Goal**: 阻止危险工具通过 MCP 端点执行,绕过 chat confirmation 流程。 **Requirements**: R2 **Dependencies**: 无 **Files**: - `src/agentkit/mcp/server.py`(修改 `_all_tools`、`_find_tool`、REST `call_tool`、JSON-RPC `tools/call`) - `tests/unit/mcp/test_server_dangerous_tools.py`(新建) **Approach**: 在 `server.py` 顶部定义 `_MCP_BLOCKED_TOOLS` 黑名单(`shell`、`file_write`、`file_delete`、`terminal_execute`)。`_all_tools()` 和 `_find_tool()` 过滤黑名单工具。`list_tools` 和 JSON-RPC `tools/list` 不返回黑名单工具。`call_tool` 对黑名单工具返回 404。 **Technical design**(directional guidance): ```python # ponytail: 黑名单需手动维护,新增危险工具需同步更新。 # 天花板:若工具名动态生成或别名注册,黑名单可能漏判。 # 升级路径:Tool 基类增加 requires_confirmation 元数据字段。 _MCP_BLOCKED_TOOLS: frozenset[str] = frozenset({ "shell", "file_write", "file_delete", "terminal_execute", }) ``` **Patterns to follow**: `shell.py` 已有 `_DANGEROUS_BINARIES` frozenset 模式。 **Test scenarios**: - **Happy path**: `GET /tools/list` 不返回黑名单工具 - **Happy path**: `POST /tools/call` with `{"name": "safe_tool"}` 正常执行 - **Error path**: `POST /tools/call` with `{"name": "shell"}` → 404 - **Error path**: `POST /tools/call` with `{"name": "file_delete"}` → 404 - **Integration**: JSON-RPC `tools/call` with `{"method": "tools/call", "params": {"name": "shell"}}` → `isError: True` - **Edge case**: JSON-RPC `tools/list` 不包含黑名单工具 **Verification**: `pytest tests/unit/mcp/test_server_dangerous_tools.py -v` 全绿;黑名单工具在 list 和 call 端点均不可见。 --- ### U3. SecretsStore Redis 迁移 **Goal**: 加密凭证存储迁移到 Redis,多 worker 共享,保留 AES-256-GCM 加密层。 **Requirements**: R3, R5 **Dependencies**: 无(可与 U4 并行,共享 Redis 注入基础设施) **Files**: - `src/agentkit/channels/secrets.py`(修改 `SecretsStore.__init__`、`set_secret`、`get_secret`、`delete_secret`、`list_keys`) - `src/agentkit/server/routes/channels.py`(修改 `_get_secrets_store` 注入 Redis) - `src/agentkit/server/app.py`(lifespan 中将 `working_redis_client` 传入 SecretsStore) - `tests/unit/channels/test_secrets_redis.py`(新建) **Approach**: `SecretsStore.__init__` 新增 `redis` 参数(`aioredis.Redis | None`)。`_store` 字典替换为 Redis 调用:`set_secret` → `redis.set(f"secrets:{key}", entry.model_dump_json())`;`get_secret` → `redis.get` + `SecretEntry.model_validate_json`;`delete_secret` → `redis.delete`;`list_keys` → `redis.scan_iter`。加密层(`encrypt`/`decrypt`/`_derive_key`)不变。Redis 为 None 时降级到内存(仅开发模式,log warning)。 **Patterns to follow**: `RedisBus._get_redis()` 懒初始化模式;`SharedWorkspace` 的 `redis_client` 注入模式。 **Test scenarios**: - **Happy path**: `set_secret("k", "v")` 后 `get_secret("k")` 返回 `"v"`(Redis mock) - **Happy path**: `delete_secret("k")` 后 `get_secret("k")` 返回 None - **Happy path**: `list_keys(prefix="feishu:")` 返回匹配前缀的 key 列表 - **Edge case**: Redis 为 None(降级模式)→ 内存字典行为,log warning - **Error path**: Redis 连接失败 → `get_secret` 抛异常或返回 None(fail-closed) - **Integration**: 加密-存储-读取-解密往返:明文 → encrypt → Redis SET → Redis GET → decrypt → 明文一致 **Verification**: `pytest tests/unit/channels/test_secrets_redis.py -v` 全绿;多 worker 场景下 worker A `set_secret` 后 worker B `get_secret` 能读到(需 Redis faker 或集成测试)。 --- ### U4. Channels 全局状态 Redis 迁移 **Goal**: nonce dedup、rate limit、backpressure、渠道配置迁移到 Redis,多 worker 一致。 **Requirements**: R4, R5, R6 **Dependencies**: U3(共享 Redis 注入基础设施) **Files**: - `src/agentkit/server/routes/channels.py`(修改 `_channels`、`_rate_limits`、`_seen_nonces`、`_pending_webhook_tasks` 相关逻辑) - `tests/unit/channels/test_webhook_redis_state.py`(新建) **Approach**: 1. **nonce dedup** → Redis `SET NX EX`:`await redis.set(f"nonce:{nonce}", "1", ex=300, nx=True)` 返回 None 表示重复。 2. **rate limit** → Redis ZSET 滑动窗口:`ZREMRANGEBYSCORE` 清理过期 + `ZADD` + `ZCARD` 计数。 3. **backpressure** → Redis `INCR`/`DECR` 共享计数器:`INCR webhook:concurrent`,超限 `DECR` 并返回 429,执行完毕 `DECR`。`EXPIRE 30s` 防 crash 计数不归零。 4. **渠道配置** → Redis Hash:`HSET channel:{id}` 存配置 JSON,`HGETALL` 读取。`_adapter_cache` 保留进程内缓存(只读快照,配置变更时 invalidate)。 5. **Redis 注入**:webhook handler 通过 `request.app.state.working_redis_client` 获取连接。 **Technical design**(directional guidance): ```python # nonce dedup — 原子操作,TTL 自动过期 is_new = await redis.set(f"nonce:{nonce}", "1", ex=int(_NONCE_TTL), nx=True) if not is_new: return Response(status_code=200) # 重复事件,飞书要求 3s 内响应 # rate limit — ZSET 滑动窗口 key = f"ratelimit:{client_ip}" now = time.time() pipe = redis.pipeline() pipe.zremrangebyscore(key, 0, now - _RATE_LIMIT_WINDOW) pipe.zadd(key, {str(now): now}) pipe.zcard(key) pipe.expire(key, int(_RATE_LIMIT_WINDOW)) _, _, count, _ = await pipe.execute() if count > _RATE_LIMIT_MAX: return Response(status_code=429) # backpressure — 共享计数器 current = await redis.incr("webhook:concurrent") if current > _WEBHOOK_MAX_CONCURRENT * 2: await redis.decr("webhook:concurrent") return Response(status_code=429) try: # ... process webhook ... finally: await redis.decr("webhook:concurrent") ``` **Patterns to follow**: Redis pipeline 模式(`usage_store.py` 已有);`_adapter_cache` 保留进程内缓存(`_invalidate_adapter_cache` 逻辑不变)。 **Test scenarios**: - **Happy path**: 首次 nonce → `SET NX` 返回 True,请求正常处理 - **Happy path**: 重复 nonce → `SET NX` 返回 None,返回 200(不重复处理) - **Happy path**: rate limit 窗口内 100 请求通过,第 101 请求返回 429 - **Happy path**: backpressure 并发 < 2x 上限时请求通过 - **Error path**: backpressure 并发 >= 2x 上限时返回 429 - **Edge case**: nonce TTL 过期后相同 nonce 可再次使用(mock `time.sleep` 或 Redis TTL) - **Edge case**: rate limit 窗口滚动后计数重置 - **Integration**: 渠道配置写入 Redis 后,新 worker 的 `_adapter_cache` miss 时从 Redis 读取并缓存 **Verification**: `pytest tests/unit/channels/test_webhook_redis_state.py -v` 全绿;`ruff check src/ && ruff format src/` 通过;现有 `tests/unit/channels/test_wecom.py` 等不回归。 --- ## Scope Boundaries ### In Scope - 4 项高优先级问题修复(H3/H4/H1/H2) - Redis 连接复用现有 `app.state.working_redis_client` - 单元测试覆盖每个修复单元 ### Out of Scope - 中优先级问题(M1-M8):feishu/slack decode 异常、dingtalk 时间戳窗口、mcp/client 性能、app.py shutdown 超时等 - 低优先级问题(L1-L3):X-Forwarded-For 信任、生产 guard 触发条件、Any 类型 - PostgreSQL 持久化迁移(Redis 已满足多实例共享需求) - MCP confirmation 协议实现(黑名单方案足够,元数据方案为后续升级路径) - Tool 基类 `requires_confirmation` 元数据重构 ### Deferred to Follow-Up Work - M1 feishu/slack `body.decode("utf-8")` 异常捕获 → 独立 PR - M4 mcp/server.py 异常消息脱敏 + REST/JSON-RPC 代码去重 → 独立 PR - M6 app.py shutdown `asyncio.gather` 超时 → 独立 PR - H4 升级路径:Tool 基类 `requires_confirmation` 元数据 → 下个迭代 ## Risks & Dependencies - **风险 1**:H1/H2 Redis 迁移后,Redis 不可用会导致 webhook 端点完全失效(fail-closed)。缓解:lifespan 启动时 ping Redis,不可用时 log error 但不阻止启动(单进程降级模式仍可用)。 - **风险 2**:U4 backpressure `INCR/DECR` 非原子,crash 可能导致计数不归零。缓解:`EXPIRE 30s` 安全网 + 定期清理。 - **依赖 1**:U4 依赖 U3 完成的 Redis 注入基础设施。 - **依赖 2**:所有单元测试使用 `fakeredis` 或 `unittest.mock.AsyncMock` mock Redis,不依赖真实 Redis 实例。 ## System-Wide Impact - **运维**:多 worker 部署(gunicorn -w 4)现在可正确共享渠道状态,无需 sticky session - **安全**:MCP 端点不再暴露危险工具;KB 缓存 fail-closed 防止数据泄漏 - **性能**:Redis 网络往返替代内存字典,单请求延迟增加 ~1ms(可接受);backpressure 跨 worker 生效 - **兼容性**:单进程开发模式(无 Redis)降级到内存存储,行为不变 ## Open Questions 无 — 所有技术决策已在 KTD1-KTD3 中明确。