Jelajahi Sumber

fix: 处理首次未执行审批,后续执行报错的Bug

yangxiaolong 1 bulan lalu
induk
melakukan
eea8eb0da2

+ 58 - 7
src/vendor_guard/cli.py

@@ -15,7 +15,9 @@ import getpass
 import os
 import subprocess
 import sys
+from collections.abc import Callable
 from datetime import datetime
+from typing import Literal
 from uuid import uuid4
 
 from langgraph.types import Command
@@ -57,6 +59,30 @@ def _build_utf8_reexec_command(arguments: list[str]) -> list[str]:
     ]
 
 
+def _run_utf8_child(
+    arguments: list[str],
+    environment: dict[str, str],
+    *,
+    runner: Callable[..., subprocess.CompletedProcess] | None = None,
+) -> int:
+    """运行 UTF-8 子进程,并把父进程收到的 Ctrl+C 转成标准退出码。
+
+    Windows 终端会把 Ctrl+C 同时广播给等待中的父进程和实际执行 Agent 的
+    子进程。子进程可以优雅处理审批取消,但父进程的 subprocess.run() 仍可能
+    收到 KeyboardInterrupt;这里统一转换为 130,避免父层再次打印 Traceback。
+    """
+    runner = runner or subprocess.run
+    try:
+        completed = runner(
+            _build_utf8_reexec_command(arguments),
+            env=environment,
+            check=False,
+        )
+    except KeyboardInterrupt:
+        return 130
+    return completed.returncode
+
+
 def _ensure_utf8_mode() -> None:
     """
     避免 Windows 将 Deep Agents 子进程的 UTF-8 输出按 GBK 解码。
@@ -70,12 +96,11 @@ def _ensure_utf8_mode() -> None:
     environment = os.environ.copy()
     environment["PYTHONUTF8"] = "1"
     environment["PYTHONIOENCODING"] = "utf-8"
-    completed = subprocess.run(
-        _build_utf8_reexec_command(sys.argv[1:]),
-        env=environment,
-        check=False,
+    exit_code = _run_utf8_child(
+        sys.argv[1:],
+        environment,
     )
-    raise SystemExit(completed.returncode)
+    raise SystemExit(exit_code)
 
 
 def _print_final_answer(result) -> None:
@@ -111,6 +136,23 @@ def _approval_outcome_message(
     )
 
 
+def _read_approval_decision(
+    input_func: Callable[[str], str] | None = None,
+) -> Literal["approve", "reject", "cancel"]:
+    """读取人工审批输入,并把终端中断与业务拒绝明确分开。
+
+    只有显式输入 y 才表示 approve;普通的 n 或空输入表示 reject。Ctrl+C 和
+    stdin 关闭说明用户没有完成审批,返回 cancel,调用方不得把它记录成拒绝。
+    input_func 参数用于单元测试注入,不需要启动真实交互终端。
+    """
+    input_func = input_func or input
+    try:
+        raw_decision = input_func("批准执行?[y/N] ")
+    except (KeyboardInterrupt, EOFError):
+        return "cancel"
+    return "approve" if raw_decision.strip().lower() == "y" else "reject"
+
+
 def _print_trace_url(settings: Settings, thread_id: str, *, client=None) -> str | None:
     """查询并打印本次 Trace 链接;失败时不影响业务流程。"""
     try:
@@ -223,10 +265,19 @@ def main() -> None:
                 )
                 if trace_url:
                     print("请先打开以上链接检查执行过程,再决定是否批准。")
-            approved = input("批准执行?[y/N] ").strip().lower() == "y"
+            decision = _read_approval_decision()
+            if decision == "cancel":
+                # cancel 不是业务拒绝:没有恢复工作流,也不写审批审计记录。
+                # 使用 130(128 + SIGINT)让脚本或 CI 能识别本次运行由用户取消。
+                print(
+                    "\n已取消本次人工审批:未作出批准或拒绝决定,"
+                    "提交工具未执行。报告已保留,重新运行时会自动归档。"
+                )
+                raise SystemExit(130)
+
             # 用户只需显式输入 y 才会 approve;空输入、n 和其他内容均按 reject
             # 处理,符合有副作用操作的 fail-closed 原则。
-            decision = "approve" if approved else "reject"
+            approved = decision == "approve"
             handled_at = datetime.now().astimezone()
             request_args = request["args"]
             report_path = resolve_report_path(

+ 24 - 8
src/vendor_guard/report_metadata.py

@@ -13,17 +13,21 @@ from pathlib import Path
 from uuid import uuid4
 
 
-# 模型生成的标题可能带阿拉伯数字、中文序号或英文后缀,例如:
+# 模型生成的标题层级和文案并不固定,可能带阿拉伯数字、中文序号或英文后缀:
+# ``## 3. 安全审查(Security Review)``、
 # ``### 2.1 安全域 — 风险等级:medium``、
 # ``### 3.1 安全审查(Security Review)``。
-# 这里只定位三级安全章节,后续再从该章节中检查表头和风险等级
+# 这里允许二至四级标题,后续按照实际标题层级截取整个安全章节
 _SECURITY_SECTION_HEADING = re.compile(
-    r"^###\s+"
+    r"^(?P<marks>#{2,4})\s+"
     r"(?:(?:\d+(?:\.\d+)*|[一二三四五六七八九十]+)[.、))]?\s*)?"
     r"安全(?:域|审查)",
     re.MULTILINE,
 )
-_LEVEL_THREE_HEADING = re.compile(r"^###\s+", re.MULTILINE)
+_MARKDOWN_SECTION_HEADING = re.compile(
+    r"^(?P<marks>#{2,4})\s+",
+    re.MULTILINE,
+)
 
 
 def archive_existing_report(
@@ -119,9 +123,21 @@ def validate_report_risk_semantics(report_path: Path) -> None:
     if security_heading is None:
         raise ValueError("报告缺少安全域风险明细")
 
-    # 截取当前三级标题到下一个三级标题之间的内容,避免把法务或财务表格中的
-    # “证据状态”误当成安全域已经满足要求。
-    next_heading = _LEVEL_THREE_HEADING.search(content, security_heading.end())
+    # 安全章节内部可能继续使用更深一级的子标题。章节边界必须是“同级或更高”
+    # 的下一个标题:例如安全章节为 ## 时,### 3.1/3.2 都属于其正文,直到
+    # 下一个 ## 法务审查才结束。
+    security_heading_level = len(security_heading.group("marks"))
+    next_heading = next(
+        (
+            heading
+            for heading in _MARKDOWN_SECTION_HEADING.finditer(
+                content,
+                security_heading.end(),
+            )
+            if len(heading.group("marks")) <= security_heading_level
+        ),
+        None,
+    )
     security_section = (
         content[security_heading.start():]
         if next_heading is None
@@ -140,7 +156,7 @@ def validate_report_risk_semantics(report_path: Path) -> None:
             and (
                 "安全 (Security)" in line
                 or "安全(Security)" in line
-                or re.search(r"\|\s*\**安全(?:域|审查)\**\s*\|", line)
+                or re.search(r"\|\s*\**安全(?:域|审查)?\**\s*\|", line)
             )
         ),
         "",

+ 32 - 0
tests/test_cli.py

@@ -7,6 +7,8 @@ import unittest
 from vendor_guard.cli import (
     _approval_outcome_message,
     _build_utf8_reexec_command,
+    _read_approval_decision,
+    _run_utf8_child,
     _should_reexec_utf8,
 )
 
@@ -23,6 +25,18 @@ class CliTests(unittest.TestCase):
         self.assertEqual(command[1:5], ["-X", "utf8", "-m", "vendor_guard.cli"])
         self.assertEqual(command[5:], ["ACME"])
 
+    def test_parent_utf8_wrapper_converts_ctrl_c_to_exit_130(self) -> None:
+        def interrupted_runner(*_args, **_kwargs):
+            raise KeyboardInterrupt
+
+        exit_code = _run_utf8_child(
+            ["ACME"],
+            {"PYTHONUTF8": "1"},
+            runner=interrupted_runner,
+        )
+
+        self.assertEqual(exit_code, 130)
+
     def test_rejected_approval_is_reported_as_completed(self) -> None:
         message = _approval_outcome_message(
             vendor_name="ACME",
@@ -49,6 +63,24 @@ class CliTests(unittest.TestCase):
         self.assertIn("已批准执行", message)
         self.assertIn("提交工具已执行", message)
 
+    def test_ctrl_c_cancels_approval_without_treating_it_as_reject(self) -> None:
+        def interrupted_input(_prompt: str) -> str:
+            raise KeyboardInterrupt
+
+        self.assertEqual(
+            _read_approval_decision(interrupted_input),
+            "cancel",
+        )
+
+    def test_eof_cancels_approval_without_treating_it_as_reject(self) -> None:
+        def closed_input(_prompt: str) -> str:
+            raise EOFError
+
+        self.assertEqual(
+            _read_approval_decision(closed_input),
+            "cancel",
+        )
+
 
 if __name__ == "__main__":
     unittest.main()

+ 19 - 0
tests/test_report_metadata.py

@@ -121,6 +121,25 @@ class ReportMetadataTests(unittest.TestCase):
 
         validate_report_risk_semantics(self.report_path)
 
+    def test_accepts_level_two_security_review_with_subsections(self) -> None:
+        self.report_path.write_text(
+            "# ACME 报告\n\n"
+            "| 专业域 | 风险等级 |\n"
+            "|---|---|\n"
+            "| 安全审查 | medium |\n\n"
+            "## 3. 安全审查(Security Review)\n\n"
+            "### 3.1 总体评价\n\n"
+            "风险等级:medium\n\n"
+            "### 3.2 逐项风险\n\n"
+            "| 风险项 | 风险等级 | 证据状态 |\n"
+            "|---|---|---|\n"
+            "| PT-02 | medium | 已验证 |\n\n"
+            "## 4. 法务审查\n",
+            encoding="utf-8",
+        )
+
+        validate_report_risk_semantics(self.report_path)
+
 
 if __name__ == "__main__":
     unittest.main()