Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se
| **项目配置拒审**(issue #49 洞三) | 被审目录链(cwd 到 git 根)存在 `zcode.json` / `.zcode/config.json` 时 **fail-closed 拒审**(`isError` + 顶层结构化 `refusal` 键;review-gate 据此按终态告警处理,不当临时失败重试)——该文件的 `mcp.servers` 会被 zcode 启动时直接 spawn。审查子进程 `--cwd` 恒为隔离沙箱(含空 `.git` 阻断配置上溯),被审仓库根目录经 prompt 以绝对路径提供 | `ZCODE_BRIDGE_TRUST_PROJECT_CONFIG=1` 显式放行可信仓库(`0/false/no/off/disabled` 均视为关) |
| **provider 错误解析** | 识别 429 / 1302 / `Too Many Requests` / `请求过于频繁` / `retry-after`,区分限流/配额/其他 | — |
| **有限重试 + 退避** | 仅对**限流**错误重试(配额/Unauthorized 不重试),退避用 retry-after 或指数退避(`2^n+1`) | `ZCODE_BRIDGE_MAX_RETRIES`(默认 3) |
| **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 300s,下限 30s) |
| **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s,下限 30s;旧默认 300s 对 depth=deep 普遍不够——闸门实测两仓中位 305s/409s、57%/71% 超 300s,未显式设值的调用方对 deep 档多数超时,见 #54。review-gate 自身显式设 3600,不受默认值影响) |
| **code 参数体积上限** | `zcode_review` 的 `code` 参数超过上限即截断,防超大内联代码撑爆调用 | `ZCODE_BRIDGE_CODE_MAX`(默认 500KB) |
| **zcode 输出体积上限** | zcode stdout 输出超过上限即截断 | `ZCODE_BRIDGE_MAX_OUTPUT`(默认 10MB) |

Expand Down Expand Up @@ -299,7 +299,7 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D
>
> 实测备注(2026-08-08,GC-8G):① 独立调用时 mimosa 也会在被扫项目写一个小会话状态文件(`.mimosa/hook-state/sess_*.continue.json`,约 200 字节,无害)——即 bridge 自身的代码路径对被扫目录只读,但 mimosa 引擎会落这个状态文件,说"完全只读"不准确;② 从非登录 shell(systemd unit、cron、`sudo -u` 直调)启动时 PATH 可能不含 `~/.local/bin`,需显式 `export PATH="$HOME/.local/bin:$PATH"` 否则找不到 `zcode`。
>
> 并发与阻塞边界(狗食 review P2-4/P2-5):mimosa 预扫**不在** review 文件锁内(确定性引擎无 LLM 限流问题),只有 zcode 复核阶段持锁——并发扫同一项目时 mimosa 的 hook-state 文件各写各的会话,无冲突。最坏阻塞时长估算:锁等待 300s + 单次调用 `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 300s)×(1 + `ZCODE_BRIDGE_MAX_RETRIES` 默认 3)+ 限流退避,极端情况单次 tool 调用可阻塞约 20 分钟;depth=deep 时前面还要再加 mimosa 异步扫描预算(`ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT` 默认 900s)。调用方应把 MCP 超时设到相应量级。
> 并发与阻塞边界(狗食 review P2-4/P2-5):mimosa 预扫**不在** review 文件锁内(确定性引擎无 LLM 限流问题),只有 zcode 复核阶段持锁——并发扫同一项目时 mimosa 的 hook-state 文件各写各的会话,无冲突。最坏阻塞时长估算:锁等待 300s + 单次调用 `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s)×(1 + `ZCODE_BRIDGE_MAX_RETRIES` 默认 3)+ 限流退避,极端情况单次 tool 调用可阻塞约 85 分钟;depth=deep 时前面还要再加 mimosa 异步扫描预算(`ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT` 默认 900s)。调用方应把 MCP 超时设到相应量级——**该默认值是 #54 后按 deep 实测中位(305s/409s)上调的**,代价是挂死子进程占用文件锁的时长随之变长(锁等待仍 300s)。**注意并发直调语义(#54)**:deep 常态已超 300s,两个并发直调 review 里后到者会在锁等待 300s 耗尽后**快速失败**(结构化键 `lock_timeout`,与 `timeout` 同型),而非排队等前者跑完——调用方应稍后重试。

### 聚焦审查 prompt 建议

Expand Down
41 changes: 33 additions & 8 deletions packages/mcp-server/zcode-mcp-server
Original file line number Diff line number Diff line change
Expand Up @@ -1235,8 +1235,9 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False):
锁与重试语义见 ReviewFileLock / _parse_provider_error 注释 (issue #3)。
structured_output=True (仅 zcode_pr_review 用, #35): 失败时若 stderr 命中
1308 额度错误, result 顶层附 "quota_limit": {"code": 1308, "reset_at":
<unix 秒>}; 成功时有 usage 时附 "usage": {"total_tokens": <int>}。其余
review tool 的 result 形状保持不变。
<unix 秒>}; 子进程超时附 "timeout": {"seconds": <int>}; 锁等待超时附
"lock_timeout": {"seconds": <int>} (均 #54); 成功时有 usage 时附
"usage": {"total_tokens": <int>}。其余 review tool 的 result 形状保持不变。
"""
# 有限重试配置 (issue #3 子项3c): 仅对 provider 限流错误重试, 退避指数。
# clamp 到 >=0, 防止 ZCODE_BRIDGE_MAX_RETRIES=-1 导致不执行 zcode (Codex P2)
Expand Down Expand Up @@ -1317,19 +1318,43 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False):
return ok_result

except subprocess.TimeoutExpired:
return {"content": [{"type": "text", "text": f"zcode review 超时 ({timeout}s)"}],
"isError": True}
err_result = {"content": [{"type": "text",
"text": f"zcode review 超时 ({timeout}s)"}],
"isError": True}
if structured_output:
# #54: 超时结构化标因 — 与 quota_limit/refusal 同型的机器契约
# (result 顶层附加键), 让调用方能区分「预算不足/过慢」与
# 其他失败 (如限流/拒审), 而不必解析 isError 文本。
err_result["timeout"] = {"seconds": timeout}
return err_result
# 理论不可达 (循环内都 return), 兜底
return {"content": [{"type": "text", "text": f"zcode 调用失败: 重试耗尽 {last_err}"}],
"isError": True}
except TimeoutError as e:
return {"content": [{"type": "text", "text": f"zcode review 获取锁超时: {e}"}],
"isError": True}
err_result = {"content": [{"type": "text",
"text": f"zcode review 获取锁超时: {e}"}],
"isError": True}
if structured_output:
# #54: 与 timeout 键同型的锁等待标因 — 深审常态 >300s 后, 并发
# 直调的第二个 review 会在锁等待耗尽时快速失败 (fail-fast 排队
# 而非等待), 调用方据此可区分「稍后重试」与其他失败。
err_result["lock_timeout"] = {"seconds": 300}
return err_result


def _review_timeout():
"""review 单次 zcode 调用超时 (秒), ZCODE_BRIDGE_REVIEW_TIMEOUT 可配。"""
return max(30, _env_int("ZCODE_BRIDGE_REVIEW_TIMEOUT", 300, maximum=3600))
"""review 单次 zcode 调用超时 (秒), ZCODE_BRIDGE_REVIEW_TIMEOUT 可配。

默认 1200 (#54): 300 对 depth=deep 普遍不够 — deep 复核是多轮「读文件+
推理」循环, 实测两仓各 21/28 次闸门深审的中位时长为 305s/409s, 57%/71%
超 300s; 慢模型 (TTFT 1-3min/请求) 下更甚。300 默认曾让未显式设
ZCODE_BRIDGE_REVIEW_TIMEOUT 的调用方 (人工/agent 的 --call 与 MCP 直调)
对 deep 档多数超时 (gate 自身不受影响, 见 review-gate 的显式 3600)。
注意代价: 挂死/长跑子进程占用 ReviewFileLock 的时长随之变长 (锁等待仍
300s) — 深审常态已超 300s, 并发直调的第二个 review 会在锁等待耗尽后
快速失败 (结构化键 lock_timeout), 由调用方稍后重试, 而非排队等完。
"""
return max(30, _env_int("ZCODE_BRIDGE_REVIEW_TIMEOUT", 1200, maximum=3600))


def _write_temp(text, prefix):
Expand Down
5 changes: 3 additions & 2 deletions packages/review-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ state 文件(默认 `~/.local/state/zcode-review-gate/state.json`)记录每

deep 档审查耗时长:gate 起审查子进程时已自动透传
`ZCODE_BRIDGE_REVIEW_TIMEOUT=3600`,自身等子进程的总超时再加 120s 留给
mimosa 扫描与进程收尾(=3720),mcp-server 侧不会以默认 300s 提前掐断
zcode。
mimosa 扫描与进程收尾(=3720);mcp-server 未显式设值时的默认是 1200s
(#54 后按 deep 实测中位上调,上限 3600),对长尾 deep 仍偏紧,故 gate
仍显式钉 3600。

## 限制

Expand Down
3 changes: 2 additions & 1 deletion packages/review-gate/zcode-review-gate
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,8 @@ def run_review(cfg, clone, base_ref, head_sha):
只做进程调用与结果解包。base 必须带 origin/ 前缀 — mcp-server 的 diff
是 base...head 三点语法 (merge-base 语义), 引用要指向远端跟踪分支。
ZCODE_BRIDGE_REVIEW_TIMEOUT 显式透传 ZCODE_REVIEW_TIMEOUT (3600):
mcp-server 侧 zcode 审查默认 300s 超时, deep 档远远不够; gate 自身
mcp-server 侧未显式设值时默认 1200s (#54, 上限 3600), 对 deep 长尾
仍偏紧, 故 gate 仍显式钉 3600; gate 自身
等子进程的总超时 REVIEW_TIMEOUT 在此基础上再加 120s, 留给 mimosa
扫描与进程收尾, 防 zcode 报告已出却被 gate 提前掐掉。

Expand Down
87 changes: 85 additions & 2 deletions tests/test_security_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
与 gate 正则路径对拍)/非 1308 不加键/isError 文本保留 1308 串 (旧 gate
兼容)/usage.totalTokens 透传两态/其他 review tool 形状不变/--call 信封
序列化存活
- 超时结构化标因 (issue #54): _review_timeout 默认 1200 可配; 子进程超时
附 timeout 键/锁等待超时附 lock_timeout 键 (均仅 structured_output);
超时不进限流重试循环 (调用次数=1); 普通路径 result 形状不变

运行: python3 tests/test_security_review.py
依赖: 仅 Python 标准库 + zcode-mcp-server 模块
Expand Down Expand Up @@ -176,9 +179,9 @@ def test_rc3_prompt_pins_review_only(self):
self.assertIn("只读工具", prompt)

def test_rc4_timeout_env_configurable(self):
"""RC4: 单次超时由 ZCODE_BRIDGE_REVIEW_TIMEOUT 控制 (默认 300)"""
"""RC4: 单次超时由 ZCODE_BRIDGE_REVIEW_TIMEOUT 控制 (默认 1200, #54)"""
mod = self.mod
self.assertEqual(mod._review_timeout(), 300)
self.assertEqual(mod._review_timeout(), 1200)
os.environ["ZCODE_BRIDGE_REVIEW_TIMEOUT"] = "60"
self.assertEqual(mod._review_timeout(), 60)
os.environ["ZCODE_BRIDGE_REVIEW_TIMEOUT"] = "1" # clamp 下限 30
Expand Down Expand Up @@ -242,6 +245,86 @@ def fake_run(cmd, *a, **kw):
self.assertLess(len(captured["content"]), 11000, "截断后应在上限附近")


class TestTimeoutAttribution(_EnvGuard):
"""#54: 超时路径的结构化标因 — 与 quota_limit/refusal 同型的顶层附加键。"""

@classmethod
def setUpClass(cls):
cls.mod = _load_mcp_module()

def _run_with_timeout_exc(self, structured_output):
"""patch subprocess.run 抛 TimeoutExpired, 直调 _run_zcode_headless。

返回 (result, 子进程调用次数) — 计数用于钉住「超时不进限流重试循环」
(对齐 SQ1 对 1308 不重试的钉法)。
"""
mod = self.mod
calls = []

def fake_run(cmd, *a, **kw):
calls.append(cmd)
raise subprocess.TimeoutExpired(cmd, kw.get("timeout", 0))

saved = mod.subprocess.run
mod.subprocess.run = fake_run
os.environ["ZCODE_BRIDGE_REVIEW_LOCK"] = "0"
try:
result = mod._run_zcode_headless(
["zcode", "--prompt", "x"], env={}, timeout=5,
structured_output=structured_output)
finally:
mod.subprocess.run = saved
return result, len(calls)

def test_ta1_timeout_structured_key(self):
"""TA1: structured_output=True 时超时结果附 timeout 键, 且不重试"""
result, n = self._run_with_timeout_exc(structured_output=True)
self.assertTrue(result.get("isError"))
self.assertEqual(result.get("timeout"), {"seconds": 5})
self.assertIn("超时 (5s)", result["content"][0]["text"])
self.assertEqual(n, 1, "超时不得进入限流重试循环")

def test_ta2_timeout_plain_shape_unchanged(self):
"""TA2: structured_output=False 时 result 形状不变 (无 timeout 键)"""
result, n = self._run_with_timeout_exc(structured_output=False)
self.assertTrue(result.get("isError"))
self.assertNotIn("timeout", result)
self.assertNotIn("quota_limit", result)
self.assertEqual(n, 1)

def test_ta3_lock_timeout_structured_key(self):
"""TA3: 锁等待超时附 lock_timeout 键 (与 timeout 键语义区分), 不起子进程"""
mod = self.mod

class _LockTimeout:
def __init__(self, timeout=300):
pass

def __enter__(self):
raise TimeoutError("锁等待超时 (300s), 可能有并发 review 在跑")

def __exit__(self, *a):
return False

def fail_run(*a, **kw): # 锁失败必须在 spawn 之前, 起了即测试失败
raise AssertionError("锁超时路径不应执行 zcode 子进程")

saved_lock, saved_run = mod.ReviewFileLock, mod.subprocess.run
mod.ReviewFileLock = _LockTimeout
mod.subprocess.run = fail_run
try:
result = mod._run_zcode_headless(
["zcode", "--prompt", "x"], env={}, timeout=5,
structured_output=True)
finally:
mod.ReviewFileLock = saved_lock
mod.subprocess.run = saved_run
self.assertTrue(result.get("isError"))
self.assertEqual(result.get("lock_timeout"), {"seconds": 300})
self.assertNotIn("timeout", result, "锁等待超时不得冒充子进程预算超时")
self.assertIn("锁", result["content"][0]["text"])


class TestProjectConfigGuard(_EnvGuard):
"""issue #49 洞三防护: 被审目录链上的 zcode 项目配置 → 拒审 + 干净工作目录。

Expand Down
Loading