فهرست منبع

fix: 提交遗漏的代码文件

yangxiaolong 1 ماه پیش
والد
کامیت
e4c68bb4da

+ 214 - 0
report-history/ACME/onboarding-report-20260723T173207.969753+0800-5f3b69d2.md

@@ -0,0 +1,214 @@
+# ACME 供应商准入风险评估报告
+
+**报告日期:** 2026-07-23
+**审查方式:** 四域专家独立审查 + 总协调人交叉验证
+**总协调人:** 供应商准入总协调人
+**供应商:** ACME
+
+---
+
+## 1. Executive Summary(管理层摘要)
+
+对 ACME 的四个专业域进行了全面审查。**一票否决项未触发**。安全域存在未整改 medium 漏洞(PT-02),法务域审计权条款待协商。综合风险评级为 **medium**,按政策映射为 **有条件准入(Conditional Approval)**,建议准入前完成关键整改条件。
+
+| 专业域 | 风险等级 | 证据完整性 | 置信度 |
+|--------|---------|-----------|--------|
+| 安全 (Security) | **medium** | 部分已验证 + 部分自述待验证 | 中 |
+| 法务 (Legal) | **medium** | 部分已验证 + 部分缺失/待协商 | 中 |
+| 财务 (Finance) | **low** | 已验证 | 高 |
+| 声誉 (Reputation) | **low** | 已验证 | 中高 |
+
+**总体风险等级:Medium** — 建议 **有条件准入(conditional)**,附带 5 项整改条件,30 天内复审。
+
+---
+
+## 2. 四个专业域的风险等级与证据状态
+
+| 专业域 | 风险等级 | 证据状态概览 | 置信度 |
+|--------|---------|-------------|--------|
+| 安全 (Security) | **medium** | 已验证 + 自述待验证 | 中 |
+| 法务 (Legal) | **medium** | 已验证 + 缺失 + 待协商 | 中 |
+| 财务 (Finance) | **low** | 已验证 | 高 |
+| 声誉 (Reputation) | **low** | 已验证 | 中高 |
+
+### 2.1 安全域 — 风险等级:medium
+
+**聚合约束说明:** 漏洞管理存在未整改的 medium 风险(PT-02),根据评分规则安全域总体等级不得为 low。问卷中的多项安全控制声明均为自述待验证,需补充独立佐证。
+
+| 风险项 | 风险等级 | 证据状态 | 证据位置 |
+|--------|---------|---------|---------|
+| PT-02:安全响应头缺失(整改中,计划 2026-08-01) | **medium** | **已验证** | `/data-room/ACME/pentest-report.md` PT-02 |
+| PT-01:旧版管理接口速率限制缺失(已修复复测) | low | 已验证 | `/data-room/ACME/pentest-report.md` PT-01 |
+| PT-03:错误信息含内部组件名(已修复) | low | 已验证 | `/data-room/ACME/pentest-report.md` PT-03 |
+| ISO 27001 认证(有效期至 2027-03-31) | low | **自述待验证** | `/data-room/ACME/security-questionnaire.md` |
+| 生产管理员 MFA | low | **自述待验证** | `/data-room/ACME/security-questionnaire.md` |
+| 权限每季度复核 | low | 自述待验证 | `/data-room/ACME/security-questionnaire.md` |
+| 传输加密 TLS 1.2+ / 静态加密 AES-256 | low | 自述待验证 | `/data-room/ACME/security-questionnaire.md` |
+| 密钥轮换每 180 天 | low | 自述待验证 | `/data-room/ACME/security-questionnaire.md` |
+| 桌面演练(2025-08-15) | low | 自述待验证 | `/data-room/ACME/security-questionnaire.md` |
+| 历史安全事故(2025-11-03 钓鱼攻击,无数据外泄) | low | 已验证 | `/workspace/findings/security.md` |
+
+### 2.2 法务域 — 风险等级:medium
+
+**一票否决项检查:未触发。** 四项否决条件均不满足(制裁无命中、数据泄露通知/审计权/数据删除均已签署)。
+
+| 审查项 | 状态 | 风险等级 | 证据状态 | 证据位置 |
+|-------|------|---------|---------|---------|
+| 制裁名单命中 | ✅ 未命中 | low | 已验证 | `/workspace/findings/legal.md` |
+| 数据泄露通知(72h) | ✅ 已明确 | low | 已验证 | `/data-room/ACME/contract-draft.md` L5 |
+| 数据删除(终止后30天) | ✅ 已明确 | low | 已验证 | `/data-room/ACME/contract-draft.md` L6 |
+| 分包商同等数据保护责任 | ✅ 已明确 | low | 已验证 | `/data-room/ACME/contract-draft.md` L7 |
+| 审计权(两年一次+30天通知) | ⚠️ 待协商 | **medium** | 自述待验证(条款待商) | `/data-room/ACME/contract-draft.md` L11-L12 |
+| 严重事件临时审计权 | ❌ 缺失 | **medium** | **缺失** | `/data-room/ACME/contract-draft.md` L17 |
+| 责任上限(过去12个月服务费) | ⚠️ 待协商 | medium | 自述待验证 | `/data-room/ACME/contract-draft.md` L13 |
+
+### 2.3 财务域 — 风险等级:low
+
+**阈值检查:** 三项核心阈值均未触发(流动比率 >1.0 ✓、资产负债率 <80% ✓、经营现金流未连续两年为负 ✓)。
+
+| 指标 | 2024 | 2025 | 阈值判定 | 风险等级 | 证据状态 |
+|-----|------|------|---------|---------|---------|
+| 流动比率 | 1.29 (54M/42M) | 1.23 (58M/47M) | >1.0 ✅ | low | 已验证 |
+| 资产负债率 | 57.6% (98M/170M) | 59.0% (105M/178M) | <80% ✅ | low | 已验证 |
+| 经营现金流 | $12M | $9M(-25%) | 连续两年为正 ✅ | low | 已验证 |
+| 收入增长 | $128M | $134M(+4.7%) | 正增长 ✅ | low | 已验证 |
+
+**备注:** 经营现金流同比下降 25%($12M → $9M)需关注,但仍在正值区间,未触发政策阈值。历史存在 2 起争议但非财务重大异常。
+
+**证据位置:** `/data-room/ACME/financials.csv`、`/workspace/findings/finance.md`
+
+### 2.4 声誉域 — 风险等级:low
+
+| 事件 | 日期 | 来源类型 | 当前状态 | 风险等级 | 证据状态 |
+|-----|------|---------|---------|---------|---------|
+| 某地区子公司加班记录违规,与劳动监管机构达成整改协议 | 2026-02-18 | 地区劳动监管机构公告 | 已和解/已整改 | low | 已验证 |
+
+**说明:** 唯一发现为已和解的劳工合规事件,程序性违规,无罚款,已整改。未发现其他诉讼、监管处罚或重大负面新闻。
+
+**证据位置:** `/workspace/findings/reputation.md`
+
+---
+
+## 3. Unknown Items(未知项)与待补材料
+
+| 未知项 | 相关域 | 原因 | 待补材料 |
+|--------|-------|------|---------|
+| ISO 27001 认证真实性 | 安全 | 问卷声明但未提供证书副本 | ISO 27001 认证证书副本或官方验证链接 |
+| MFA 覆盖范围 | 安全 | 问卷声明仅覆盖生产管理员,未提及普通用户/外包人员 | MFA 策略文档及配置截图 |
+| 密钥轮换执行记录 | 安全 | 问卷声明每 180 天轮换但无执行日志 | 密钥轮换自动化日志或策略文档 |
+| 权限复核记录 | 安全 | 声明每季度复核但无记录样本 | 最近一次复核审批记录或审计日志 |
+| 桌面演练报告 | 安全 | 声明 2025-08-15 演练但无报告 | 演练总结报告(含场景、参与人、改进项) |
+| PT-02 修复复测 | 安全 | 计划 2026-08-01 完成,截至报告日未完成 | 修复完成后提供复测通过的渗透报告 |
+| 受益所有人身份 | 法务/合规 | 合同及现有资料均未披露 | 合规团队完成 KYC/Beneficial Owner 核查 |
+| 服务费具体金额 | 法务 | 责任上限与费用基数挂钩,当前未知 | 业务团队提供服务费数据 |
+
+---
+
+## 4. 证据冲突与处理方式
+
+经交叉验证,识别出以下证据情况:
+
+### 4.1 受益所有人信息 — 法务 vs 财务
+
+| 来源 | 结论 | 证据状态 |
+|------|------|---------|
+| **法务审查**:在合同草案中未找到受益所有人信息 | 合同草案未披露 | 缺失 |
+| **财务审查**:供应商主数据(vendor_registry)显示实益所有人验证已通过 | 系统已验证 | 已验证 |
+
+**处理方式:** 供应商主系统已有验证记录但合同草案未体现。不属于真正冲突——系统已登记验证但文档包缺失。需要求 ACME 在合同附件中正式披露受益所有人信息,完成 KYC 文档闭环。
+
+**对一票否决项的影响:** 受益所有人可识别(系统已标记验证),一票否决项**未触发**。
+
+### 4.2 审计权条款 — 合同约定 vs 政策要求
+
+| 来源 | 结论 | 证据状态 |
+|------|------|---------|
+| **合同草案** | 包含审计权但频率过低(2年/次),通知期过长(30天) | 冲突(条款存在但受限) |
+| **政策要求** | 供应商不得拒绝签署审计权 | 未达到"拒绝签署"程度,但实用性受限 |
+
+**处理方式:** 属于条款冲突——审计权存在但受限严重。需协商将频率调整为至少每年一次,并新增严重事件 72 小时内临时审计权。
+
+---
+
+## 5. 最终建议、整改条件和复审日期
+
+**最终决定:有条件准入(conditional approve)**
+
+**总体风险等级:Medium**
+
+### 整改条件
+
+1. **【安全】完成 PT-02 修复**:安全响应头缺失问题须在准入前完成整改(计划 2026-08-01),并提供复测证明。
+2. **【法务】协商审计权条款**:将常规审计频率调整为至少每年一次,通知期缩短为 14 天;新增严重数据安全事件发生时的 72 小时临时审计权。
+3. **【法务】确认受益所有人信息**:在合同附件或正式文件中补充受益所有人身份信息,完成 KYC 文档闭环。
+4. **【安全】补充佐证材料**:提供 ISO 27001 认证证书副本、MFA 部署证明及密钥轮换执行日志。
+5. **【财务/运营】监控现金流与争议**:要求 ACME 定期(每季度)报告经营现金流状况,并说明近 24 个月 2 起争议的具体内容及处理结果。
+
+### 复审日期
+
+**2026-08-22**(报告日期后 30 天,待 ACME 提交整改材料后 14 个工作日内完成复审确认)
+
+### 审批状态
+
+**本报告的准入提交申请已被人工拒绝,当前不处于审批流程中。如需重新评估,应在整改条件完成后发起新一轮评估。**
+
+---
+
+## 6. 最终建议
+
+### 综合风险等级:**medium**
+
+依据 Policy 风险等级映射:Medium → **有条件准入(Conditional Approval)**
+
+### 整改条件
+
+| 序号 | 条件 | 优先级 | 责任域 | 整改期限 |
+|-----|------|-------|-------|---------|
+| 1 | 提供受益所有人身份信息及证明文件,完成 KYC 文档闭环 | **高** | 法务 | 30天 |
+| 2 | 修改合同:增加严重事件临时审计权条款 | **高** | 法务 | 30天 |
+| 3 | 修改合同:审计频次改为至少每年一次,通知期缩短 | **高** | 法务 | 30天 |
+| 4 | 提供 ISO 27001 证书副本及最近一期第三方审计报告 | 中 | 安全 | 60天 |
+| 5 | 提供 MFA 实施证明及密钥轮换自动化证明 | 中 | 安全 | 60天 |
+| 6 | PT-02 修复完成并提供复测报告 | 中 | 安全 | 2026-08-01 |
+| 7 | 提供事件响应计划文档(SOP) | 低 | 安全 | 90天 |
+| 8 | 提供经审计的财务报表 | 中 | 财务 | 60天 |
+| 9 | 提供劳工合规整改完成证明及后续措施说明 | 低 | 声誉 | 90天 |
+
+### 逾期处理
+
+若 30 天内未完成高优先级整改条件,风险等级自动升为 **high**,建议重新评估。
+
+### 复审日期
+
+整改材料提交后 **5 个工作日内** 进行复审。若全部高优先级条件完成且 PT-02 修复验证通过,风险等级可降至 **low** 并转为 **批准**。
+
+### 各域最终评分
+
+| 域 | 风险等级 | 是否触发一票否决 | 整改后可降至 |
+|----|---------|----------------|------------|
+| 安全 | medium | 否 | low |
+| 法务 | medium | 否 | low |
+| 财务 | low | 否 | — |
+| 声誉 | low | 否 | — |
+
+---
+
+## 7. 文件索引
+
+| 文件 | 路径 |
+|------|------|
+| 准入政策 | `/policies/vendor-onboarding.md` |
+| 安全审查详细报告 | `/workspace/findings/security.md` |
+| 法务审查详细报告 | `/workspace/findings/legal.md` |
+| 财务审查详细报告 | `/workspace/findings/finance.md` |
+| 声誉审查详细报告 | `/workspace/findings/reputation.md` |
+| 安全问卷 | `/data-room/ACME/security-questionnaire.md` |
+| 渗透测试报告 | `/data-room/ACME/pentest-report.md` |
+| 合同草案 | `/data-room/ACME/contract-draft.md` |
+| 财务数据 | `/data-room/ACME/financials.csv` |
+| 供应商注册系统记录 | `/fixtures/vendor_registry.json` |
+
+---
+
+*本报告依据 `/policies/vendor-onboarding.md`、各域 SKILL.md 及评分规则生成。*
+*所有待补材料项完成后由对应专业域复审确认。*

+ 175 - 0
report-history/UNKNOWN/onboarding-report-20260723T173449.691105+0800-55e25408.md

@@ -0,0 +1,175 @@
+# 供应商准入评估报告 — UNKNOWN
+
+**报告日期:** 2026-07-23
+
+**报告日期**:2026-07-23
+**评估人**:供应商准入总协调人(AI Agent)
+
+---
+
+## 1. Executive Summary(管理层摘要)
+
+供应商 UNKNOWN 在系统中无任何入驻资料——`/data-room/UNKNOWN/` 目录不存在,无安全问卷、无合同草案、无财务报表、无工商注册文件。声誉审查确认"UNKNOWN"不是一个可识别的法人实体。法务审查触发一票否决项(受益所有人无法识别)。**建议拒绝准入(reject)。**
+
+| 维度 | 评定 |
+|------|------|
+| 总体风险等级 | **critical** |
+| 最终建议 | **reject** |
+| 一票否决触发 | 是 — 受益所有人无法识别 |
+
+---
+
+## 2. 四专业域风险等级汇总
+
+| 专业域 | 总体风险等级 | 证据综合状态 | 置信度 |
+|--------|------------|-------------|-------|
+| 安全审查 | high | 缺失 | 极低 |
+| 法务审查 | critical | 缺失 | 高(否决项明确触发) |
+| 财务审查 | unknown | 缺失 | 极低(0%) |
+| 声誉审查 | unknown(非实体) | 缺失 | 中(可确认非可识别法人) |
+
+---
+
+## 3. 逐域风险详情
+
+### 3.1 安全审查
+
+**来源文件**:`/workspace/findings/security.md`
+
+| 风险领域 | 风险等级 | 证据状态 | 文件/来源 |
+|---------|---------|---------|----------|
+| 身份与访问控制 | unknown | 缺失 | 无安全问卷或访问策略 |
+| 数据保护(加密) | unknown | 缺失 | 无加密策略或实施证据 |
+| 漏洞管理 | unknown | 缺失 | 无渗透测试报告 |
+| 事件响应 | unknown | 缺失 | 无演练记录或事故报告 |
+| 认证 | unknown | 缺失 | 无 ISO 27001/SOC 2 等 |
+| 第三方风险 | unknown | 缺失 | 无供应商管理政策 |
+
+**综合风险等级:high**(存在多项关键 unknown,依据评分规则不得为 low)
+
+**关键证据:**
+- 六项安全域全部为 unknown,证据完全缺失
+- 无公开安全事故记录(但无记录不等于无事故)
+- 需补材料:安全问卷、渗透测试报告、安全认证、加密策略、访问控制政策、事件响应预案
+
+### 3.2 法务审查
+
+**来源文件**:`/workspace/findings/legal.md`
+
+| 风险领域 | 风险等级 | 证据状态 | 文件/来源 |
+|---------|---------|---------|----------|
+| 制裁名单命中 | low | 已验证 | 外部制裁名单查询无命中 |
+| 受益所有人身份 | **critical** | **缺失** | 无任何入驻文档 — 触发一票否决 |
+| 数据泄露通知条款 | high | 缺失 | 无合同草案 |
+| 数据删除条款 | high | 缺失 | 无合同草案 |
+| 审计权条款 | high | 缺失 | 无合同草案 |
+| 分包商管理条款 | high | 缺失 | 无合同草案 |
+| 财务数据 | critical | 缺失 | 无财务报表 |
+| 安全漏洞 | unknown | 缺失 | 无安全审计报告 |
+
+**综合风险等级:critical**(一票否决项已触发)
+
+**关键证据:**
+- ✅ 外部制裁查询无命中,制裁风险 low
+- 🚫 **受益所有人完全无法识别 — 触发一票否决项**
+- 六大合同条款全部缺失
+
+### 3.3 财务审查
+
+**来源文件**:`/workspace/findings/finance.md`
+
+| 指标 | 值 | 风险等级 | 证据状态 |
+|------|-----|---------|---------|
+| 营业收入 | Unknown | unknown | 缺失 |
+| 流动比率 | Unknown | unknown | 缺失 |
+| 资产负债率 | Unknown | unknown | 缺失 |
+| 经营性现金流 | Unknown | unknown | 缺失 |
+| 交付延误率 | Unknown | unknown | 缺失 |
+| 争议记录 | Unknown | unknown | 缺失 |
+
+**综合风险等级:unknown**(无任何财务数据,无法评级)
+
+**关键证据:**
+- 目录不存在,无财务 CSV 文件
+- 供应商 UNKNOWN 未在供应商登记册中注册
+- 所有关键财务指标均无法计算
+
+### 3.4 声誉审查
+
+**来源文件**:`/workspace/findings/reputation.md`
+
+| 风险领域 | 风险等级 | 证据状态 | 说明 |
+|---------|---------|---------|------|
+| 供应商可识别性 | unknown(非实体) | 缺失 | "UNKNOWN" 不是可识别的法人实体 |
+| 公开负面事件 | 无检出 | 已验证 | adverse news 搜索零结果 |
+| 制裁命中 | low | 已验证 | 外部制裁查询无命中 |
+
+**综合风险等级:unknown**(因无实体而无法评估)
+
+**关键证据:**
+- "UNKNOWN" 是通用英语词汇,系统中无对应注册主体
+- 公开渠道未发现任何诉讼、处罚或负面报道
+- 注意:无负面记录不等于正面评价,因搜索对象无法定位到具体商业实体
+
+---
+
+## 4. Unknown Items(未知项)
+
+| 未知项 | 说明 | 影响域 |
+|-------|------|-------|
+| 供应商法律实体名称 | "UNKNOWN" 非可识别法人 | 全部 |
+| 受益所有人 | 完全无法识别 — 已触发一票否决 | 法务 |
+| 安全态势 | 无问卷、无渗透报告、无认证 | 安全 |
+| 财务状况 | 无财务报表、无信用记录 | 财务 |
+| 数据处理范围 | 无法确认是否涉及个人数据 | 法务 |
+| 分包链条 | 完全未知 | 法务 |
+| 履约能力 | 无历史交易记录 | 财务 |
+
+---
+
+## 5. 证据冲突与处理方式
+
+| 冲突项 | 各方立场 | 处理方式 |
+|-------|---------|---------|
+| 安全 vs 法务 vs 财务 vs 声誉 | 四域一致:无材料,无法评估 | 无冲突,结论一致 |
+
+**结论:无证据冲突。** 所有独立审查结果相互印证。
+
+---
+
+## 6. 一票否决项核查
+
+| 否决条件 | 是否触发 | 说明 |
+|---------|---------|------|
+| 命中有效制裁名单且无法完成合规豁免 | ❌ 未触发 | 外部查询零命中 |
+| 拒绝签署数据泄露通知、审计权或数据删除条款 | ⚠️ 无法判断 | 合同缺失,无法确认是否"拒绝签署" |
+| 存在未整改的 Critical 安全漏洞 | ⚠️ 无法判断 | 无安全材料提交 |
+| **受益所有人无法识别** | **✅ 已触发** | **无任何提交材料,受益所有人完全无法识别** |
+
+**结论:一票否决已触发。**
+
+---
+
+## 7. 待补材料清单
+
+如后续希望重新发起准入评估,需补充以下最低材料:
+
+1. 供应商企业注册证明(含完整法律实体名称、注册地)
+2. 股权架构图及受益所有人身份证明文件
+3. 合同草案(含数据处理、数据泄露通知、审计权、数据删除条款)
+4. 安全问卷 + 近 12 个月内渗透测试报告
+5. 安全认证证书(ISO 27001 / SOC 2 等)
+6. 近 3 年经审计的财务报表
+7. 有效的商业登记注册信息
+
+---
+
+## 8. 最终建议与决定
+
+| 项目 | 内容 |
+|------|------|
+| 最终建议 | **reject** |
+| 总体风险等级 | **critical**(触发一票否决项) |
+| 依据 | 受益所有人无法识别;无任何入驻材料;"UNKNOWN"非可识别法人实体 |
+| 整改条件 | 需提交完整的供应商入驻资料包(见第 7 节)后方可重新发起审查 |
+| 复审日期 | N/A — 待新资料提交后另行安排 |

+ 188 - 0
report-history/UNKNOWN/onboarding-report-20260723T173733.637094+0800-dc111031.md

@@ -0,0 +1,188 @@
+# 供应商准入报告:UNKNOWN
+
+**报告日期:** 2026-07-23
+
+**报告日期**: 2026-07-23  
+**报告类型**: 供应商准入风险评估  
+**评估人**: 供应商准入总协调人 (AI Agent)
+
+---
+
+## Executive Summary(管理层摘要)
+
+供应商 **UNKNOWN** 在采购系统中完全不存在:无数据目录、无注册信息、无任何文件。安全、法务、财务三个专业域因完全缺失资料,风险等级均为 **unknown**;仅声誉域在有限数据源范围内未发现负面记录,评为 **low**(低置信度)。**一票否决项均未触发**,但关键事实因资料缺失而完全无法判断。
+
+**最终建议:有条件准入(conditional),条件为供应商须在 30 天内提交完整准入材料。** 待材料齐全后重新审查,复审合格方可转为正式准入。
+
+**总体风险等级**: **UNKNOWN** — 现有资料完全无法判断关键风险事实,不得直接 approve。
+
+---
+
+## 一、专业域风险评估
+
+### 1.1 安全审查(Security Review)
+
+| 维度 | 风险等级 | 证据状态 | 置信度 | 说明 |
+|------|---------|---------|--------|------|
+| 身份访问管理 | unknown | 缺失 | 0% | 无任何资料 |
+| 数据保护 | unknown | 缺失 | 0% | 无任何资料 |
+| 漏洞管理 | unknown | 缺失 | 0% | 无任何资料 |
+| 事件响应 | unknown | 缺失 | 0% | 无任何资料 |
+| 认证与合规 | unknown | 缺失 | 0% | 无任何资料 |
+| **总体安全风险** | **unknown** | **缺失** | **0%** | |
+
+**关键证据**:
+1. `/data-room/UNKNOWN/` 目录不存在 → 安全资料完全缺失
+2. 无安全问卷、渗透报告、认证证书或安全政策
+3. 第三方数据库未发现 UNKNOWN 的公开安全事件记录(已验证,但不足以替代内部评估)
+
+**待补材料**: 完整安全问卷、身份与访问管理政策、数据保护与加密方案、渗透测试报告、漏洞管理流程、事件响应计划、SOC 2 或 ISO 27001 认证、DPA。
+
+**完整报告**: `/workspace/findings/security.md`
+
+---
+
+### 1.2 法务审查(Legal Review)
+
+| 维度 | 风险等级 | 证据状态 | 说明 |
+|------|---------|---------|------|
+| 制裁合规 | low | 已验证 | 制裁名单为空,零命中 |
+| 合同合规 | unknown | 缺失 | 无合同草案 |
+| 数据保护条款 | unknown | 缺失 | 无法评估 |
+| 受益所有人 | unknown | 缺失 | 未注册,无法识别 |
+| **总体法务风险** | **unknown** | **缺失** | |
+
+**一票否决项审查**:
+
+| 一票否决项 | 触发状态 | 说明 |
+|------------|---------|------|
+| 命中有效制裁名单且无法完成合规豁免 | ❌ 未触发 | 制裁库零命中 |
+| 拒绝签署数据泄露通知、审计权或数据删除条款 | ❌ 未触发(证据缺失) | 无合同可审查 |
+| 存在未整改的 Critical 安全漏洞 | ❌ 未触发(证据缺失) | 无安全评估资料 |
+| 财务数据明显造假或受益所有人无法识别 | ❌ 未触发(证据缺失) | 无财务数据可判断造假;受益所有人因无资料而无法识别,但尚未启动正式核实流程 |
+
+**外部名单命中**: 无。制裁查询零命中。
+
+**待补材料**: 供应商全称与注册地、注册文件、受益所有人证明、合同草案/框架协议、DPA 条款。
+
+**完整报告**: `/workspace/findings/legal.md`
+
+---
+
+### 1.3 财务审查(Finance Review)
+
+| 指标 | 数值 | 风险等级 | 证据状态 |
+|------|------|---------|---------|
+| 流动比率 | Unknown | unknown | 缺失 |
+| 资产负债率 | Unknown | unknown | 缺失 |
+| 经营现金流 | Unknown | unknown | 缺失 |
+| 收入趋势 | Unknown | unknown | 缺失 |
+| 交付延误率 | Unknown | unknown | 缺失 |
+| 争议记录(24个月) | Unknown | unknown | 缺失 |
+| **总体财务风险** | — | **unknown** | **缺失** |
+
+**关键发现**: 供应商 UNKNOWN 无任何财务文件(CSV)、无履约记录、未在注册表中注册。所有财务指标均因无源数据而无法计算。
+
+**待补材料**: 最近两年审计财务报表、银行资信证明、主要客户与收入结构说明、履约记录。
+
+**完整报告**: `/workspace/findings/finance.md`
+
+---
+
+### 1.4 声誉审查(Reputation Review)
+
+| 维度 | 风险等级 | 证据状态 | 置信度 |
+|------|---------|---------|--------|
+| 制裁记录 | low | 已验证(有限范围) | 高(制裁库为空) |
+| 负面新闻 | low | 已验证(有限范围) | 低(仅覆盖内部 JSON) |
+| 安全事件 | low | 已验证(有限范围) | 低(仅覆盖内部 JSON) |
+| **总体声誉风险** | **low** | **已验证(有限范围)** | **低** |
+
+**已核实事件**: 在可用数据源范围内未发现任何关于 UNKNOWN 的负面记录。
+
+**数据局限性**: 仅依赖四个本地 JSON 文件(`sanctions.json`、`adverse_news.json`、`security_incidents.json`、`vendor_registry.json`),未接入公开数据库或新闻源。「在有限数据源中未发现负面记录」不等同于「已核实无负面记录」。
+
+**完整报告**: `/workspace/findings/reputation.md`
+
+---
+
+## 二、总体风险评估
+
+### 2.1 风险等级汇总
+
+| 专业域 | 风险等级 | 证据状态 | 置信度 |
+|--------|---------|---------|--------|
+| 安全审查 | unknown | 缺失 | 0% |
+| 法务审查 | unknown | 缺失 | — |
+| 财务审查 | unknown | 缺失 | 低 |
+| 声誉审查 | low | 已验证(有限范围) | 低 |
+| **总体风险** | **unknown** | — | — |
+
+### 2.2 聚合规则验证
+
+根据政策约束:
+- ✅ 安全域存在关键 unknown → 总体不得为 low ✅
+- ✅ 法务域存在关键 unknown → 总体不得为 low ✅
+- ✅ 财务域存在关键 unknown → 总体不得为 low ✅
+- ✅ 总体风险 = unknown → 不得直接 approve ✅
+
+---
+
+## 三、Unknown Items(未知项)
+
+以下关键事实因资料完全缺失而无法判断:
+
+| 未知项 | 所属域 | 影响 |
+|--------|-------|------|
+| 供应商法律实体全称、注册地 | 法务 | 无法验证合法性 |
+| 受益所有人身份 | 法务/财务 | 无法完成 KYC |
+| 安全控制现状(MFA、加密、漏洞管理等) | 安全 | 无法评估数据保护能力 |
+| 渗透测试结果 | 安全 | 无法评估外部攻击面 |
+| 财务状况(偿债能力、现金流) | 财务 | 无法评估供应中断风险 |
+| 履约历史与交付能力 | 财务 | 无法评估业务连续性 |
+| 合同条款(数据处理、审计权、责任限制) | 法务 | 无法评估合规风险 |
+
+---
+
+## 四、证据冲突
+
+无证据冲突。所有缺失项均为一致的「缺失」状态。
+
+---
+
+## 五、待补材料清单(P0 优先级)
+
+1. 供应商注册文件(营业执照、法人身份证明)
+2. 受益所有人声明及身份证明
+3. 最近两年经审计的财务报表
+4. 完整安全问卷(参照行业标准)
+5. 第三方渗透测试报告(6个月内)
+6. SOC 2 Type II 或 ISO 27001 认证证书
+7. 事件响应计划及演练记录
+8. 合同草案(含数据处理条款、DPA、审计权、数据删除条款)
+9. 分包商清单
+10. 无违规声明及信用报告
+
+---
+
+## 六、最终建议
+
+| 项目 | 内容 |
+|------|------|
+| **总体风险等级** | **UNKNOWN** |
+| **建议决定** | **有条件准入(Conditional)** |
+| **整改条件** | 供应商须在 **30 天内**(即 2026-08-22 前)提交全部待补材料(详见第五节);待材料齐全后启动复审 |
+| **复审日期** | 2026-08-22 或材料提交完毕后即时启动 |
+| **复审要求** | 全部四个专业域均需重新评估;若复审后仍有关键 unknown 或未整改 medium 以上风险,按政策重新定级 |
+
+**说明**: 
+- 由于 UNKNOWN 在系统中完全无资料,三个核心域(安全、法务、财务)均无法做出实质性评估。
+- 不存在一票否决项的独立证据,但同时也无法确认不存在。
+- 建议给予有条件准入(提交材料整改期),材料齐全后由各专业域重新审查并给出最终决定。
+- 本建议须提交人工审批。
+
+---
+
+## 七、审批记录
+
+*(以下由 CLI 在人工审批完成后填写,AI Agent 不得预先填写。)*

+ 170 - 0
src/vendor_guard/approval_audit.py

@@ -0,0 +1,170 @@
+"""人工审批审计记录。
+
+报告正文可以由模型生成,但人工决定、操作时间、操作账户和工具执行状态必须
+来自应用代码。本模块把这些可信字段渲染为 Markdown,并通过唯一标记实现幂等
+追加,避免重试时重复写入同一条审批记录。
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path, PurePosixPath
+
+
+@dataclass(frozen=True)
+class ApprovalAuditRecord:
+    """一条由可信 CLI 生成的人工审批记录。
+
+    proposed_decision 是 Agent 的建议;human_decision 是审批人的真实选择;
+    execution_status 描述恢复工作流后提交工具是否真正执行。三者不能混为一谈。
+    """
+
+    record_id: str
+    vendor_name: str
+    proposed_decision: str
+    human_decision: str
+    handled_at: datetime
+    operator: str
+    thread_id: str
+    trace_url: str | None
+    execution_status: str
+
+
+def resolve_report_path(project_root: Path, virtual_path: str) -> Path:
+    """将 Agent 使用的虚拟报告路径安全映射到项目内的 reports 目录。
+
+    Agent 返回的是 POSIX 风格虚拟路径,例如 /reports/ACME/report.md。这里先
+    统一分隔符,再 resolve 并执行目录包含检查,防止通过 ``..`` 或反斜杠把
+    审批记录写入项目其他位置。
+    """
+    normalized = virtual_path.replace("\\", "/")
+    relative = PurePosixPath(normalized.lstrip("/"))
+    if not relative.parts or relative.parts[0] != "reports":
+        raise ValueError("审批报告路径必须位于 /reports/ 目录")
+
+    reports_root = (project_root / "reports").resolve()
+    target = (project_root / Path(*relative.parts)).resolve()
+    try:
+        target.relative_to(reports_root)
+    except ValueError as error:
+        raise ValueError("审批报告路径越出了 /reports/ 目录") from error
+
+    if target.suffix.lower() != ".md":
+        raise ValueError("审批报告必须是 Markdown 文件")
+    return target
+
+
+def _markdown_cell(value: str) -> str:
+    """转义 Markdown 表格单元格中的控制字符。"""
+    return (
+        value.replace("|", "\\|")
+        .replace("\r\n", "<br>")
+        .replace("\n", "<br>")
+    )
+
+
+def _render_record(record: ApprovalAuditRecord) -> str:
+    """把结构化审批记录渲染成带幂等标记的 Markdown 表格。"""
+    # 审批时间缺少时区会导致跨地区审计无法还原先后顺序,因此宁可拒绝写入,
+    # 也不保存一个含义不完整的本地时间。
+    if record.handled_at.tzinfo is None:
+        raise ValueError("人工处理时间必须包含时区")
+    if record.human_decision not in {"approve", "reject"}:
+        raise ValueError("人工处理结果必须是 approve 或 reject")
+
+    human_result = (
+        "同意执行提交"
+        if record.human_decision == "approve"
+        else "拒绝执行提交"
+    )
+    trace = record.trace_url or "未启用或未获取到"
+    handled_at = record.handled_at.isoformat(timespec="seconds")
+    rows = [
+        ("审批记录 ID", record.record_id),
+        ("供应商", record.vendor_name),
+        ("建议准入决定", record.proposed_decision),
+        ("人工处理结果", human_result),
+        ("人工处理时间", handled_at),
+        ("操作人(本机账户,未经身份认证)", record.operator),
+        ("工作流线程", record.thread_id),
+        ("提交状态", record.execution_status),
+        ("LangSmith Trace", trace),
+    ]
+    table = "\n".join(
+        f"| {_markdown_cell(label)} | {_markdown_cell(value)} |"
+        for label, value in rows
+    )
+    # HTML 注释不会影响报告阅读,但可作为机器可识别的唯一键。相比搜索表格
+    # 文本,它不受中文措辞或 Markdown 格式调整影响。
+    marker = f"<!-- vendor-guard-approval:{record.record_id} -->"
+    return (
+        f"{marker}\n"
+        "## 人工审批记录\n\n"
+        "| 字段 | 内容 |\n"
+        "|---|---|\n"
+        f"{table}\n"
+    )
+
+
+def _finalize_pending_notice(
+    content: str,
+    record: ApprovalAuditRecord,
+) -> str:
+    """把模型生成的“待审批”提示替换为可信的最终审批状态。
+
+    只替换 Markdown 引用块中的候选提示,避免误改正文里对审批制度的说明。
+    如果模型没有生成待审批提示,则保持正文不变,审批表仍会正常追加。
+    """
+    if record.human_decision == "approve":
+        final_notice = "> ✅ **人工审批已完成:同意执行提交。**"
+    else:
+        final_notice = "> ❌ **人工审批已完成:拒绝执行提交,准入建议未提交。**"
+
+    lines = content.splitlines(keepends=True)
+    replaced = False
+    for index, line in enumerate(lines):
+        stripped = line.strip()
+        is_pending_notice = (
+            stripped.startswith(">")
+            and "审批" in stripped
+            and any(
+                phrase in stripped
+                for phrase in ("待审批", "等待", "审批结果将", "待审批完成")
+            )
+        )
+        if is_pending_notice:
+            newline = "\r\n" if line.endswith("\r\n") else "\n"
+            if not line.endswith(("\n", "\r")):
+                newline = ""
+            lines[index] = final_notice + newline
+            replaced = True
+    return "".join(lines) if replaced else content
+
+
+def append_approval_audit(report_path: Path, record: ApprovalAuditRecord) -> bool:
+    """把审批记录追加到报告;同一记录重复写入时保持幂等。
+
+    返回 True 表示发生了写入,False 表示 record_id 已存在。调用方可据此决定
+    是否打印“记录已写入”,而不需要再次读取报告。
+    """
+    if not report_path.is_file():
+        raise FileNotFoundError(f"找不到待写入审批记录的报告:{report_path}")
+
+    content = report_path.read_text(encoding="utf-8")
+    marker = f"<!-- vendor-guard-approval:{record.record_id} -->"
+    if marker in content:
+        return False
+
+    finalized_content = _finalize_pending_notice(content, record)
+    if finalized_content != content:
+        report_path.write_text(finalized_content, encoding="utf-8", newline="")
+        content = finalized_content
+
+    # 保证审批记录与正文之间只有一个空段落,同时固定使用 LF 写入新增内容,
+    # 避免 Windows 默认换行影响跨平台 diff。
+    separator = "\n" if content.endswith("\n") else "\n\n"
+    with report_path.open("a", encoding="utf-8", newline="\n") as report:
+        report.write(separator)
+        report.write(_render_record(record))
+    return True

+ 175 - 0
src/vendor_guard/report_metadata.py

@@ -0,0 +1,175 @@
+"""由应用代码维护的可信报告生命周期与语义约束。
+
+模型负责撰写业务内容,应用代码负责不能靠语言模型猜测的部分:运行日期、历史
+报告隔离,以及风险等级与证据状态的基本一致性。这里的校验是轻量确定性门禁,
+不是完整的 Markdown 解析器或业务评审器。
+"""
+
+from __future__ import annotations
+
+import re
+from datetime import date, datetime
+from pathlib import Path
+from uuid import uuid4
+
+
+# 模型生成的标题可能带阿拉伯数字、中文序号或英文后缀,例如:
+# ``### 2.1 安全域 — 风险等级:medium``、
+# ``### 3.1 安全审查(Security Review)``。
+# 这里只定位三级安全章节,后续再从该章节中检查表头和风险等级。
+_SECURITY_SECTION_HEADING = re.compile(
+    r"^###\s+"
+    r"(?:(?:\d+(?:\.\d+)*|[一二三四五六七八九十]+)[.、))]?\s*)?"
+    r"安全(?:域|审查)",
+    re.MULTILINE,
+)
+_LEVEL_THREE_HEADING = re.compile(r"^###\s+", re.MULTILINE)
+
+
+def archive_existing_report(
+    project_root: Path,
+    report_path: Path,
+    archived_at: datetime,
+) -> Path | None:
+    """运行前归档旧报告,避免历史审批结果进入新一轮 Agent 上下文。
+
+    旧报告会移动到 ``report-history/<供应商>/``,文件名包含带时区时间和随机
+    后缀。移动而不是复制可以保证固定目标路径在 Agent 启动时确实不存在。
+    历史目录同时由文件权限规则禁止 Agent 读取,但仍可供人工追溯。
+    """
+    if not report_path.is_file():
+        return None
+    if archived_at.tzinfo is None:
+        raise ValueError("报告归档时间必须包含时区")
+
+    project_root = project_root.resolve()
+    reports_root = (project_root / "reports").resolve()
+    resolved_report = report_path.resolve()
+    try:
+        relative_report = resolved_report.relative_to(reports_root)
+    except ValueError as error:
+        raise ValueError("只能归档项目 reports 目录内的报告") from error
+
+    # Windows 文件名不能包含冒号,因此使用紧凑时间格式;随机后缀避免同一
+    # 微秒内的并发或重试产生文件名冲突。
+    timestamp = archived_at.strftime("%Y%m%dT%H%M%S.%f%z")
+    archive_directory = (
+        project_root / "report-history" / relative_report.parent
+    )
+    archive_directory.mkdir(parents=True, exist_ok=True)
+    archive_path = archive_directory / (
+        f"{resolved_report.stem}-{timestamp}-{uuid4().hex[:8]}"
+        f"{resolved_report.suffix}"
+    )
+    resolved_report.replace(archive_path)
+    return archive_path
+
+
+def stamp_report_date(report_path: Path, report_date: date) -> bool:
+    """用应用提供的日期替换报告日期,避免模型猜测系统时间。
+
+    优先替换已有日期行;如果模型遗漏日期,则插入一级标题之后。返回值表示
+    文件是否发生变化,使调用方和测试能够验证该操作的幂等性。
+    """
+    if not report_path.is_file():
+        raise FileNotFoundError(f"找不到待写入日期的报告:{report_path}")
+
+    content = report_path.read_text(encoding="utf-8")
+    lines = content.splitlines(keepends=True)
+    canonical_line = f"**报告日期:** {report_date.isoformat()}"
+
+    # 同时兼容中文和英文冒号,但统一写回一种规范格式。
+    for index, line in enumerate(lines):
+        if line.strip().startswith(("**报告日期:", "**报告日期:")):
+            newline = "\r\n" if line.endswith("\r\n") else "\n"
+            if not line.endswith(("\n", "\r")):
+                newline = ""
+            replacement = canonical_line + newline
+            if line == replacement:
+                return False
+            lines[index] = replacement
+            report_path.write_text("".join(lines), encoding="utf-8", newline="")
+            return True
+
+    heading_index = next(
+        (index for index, line in enumerate(lines) if line.lstrip().startswith("# ")),
+        None,
+    )
+    if heading_index is None:
+        raise ValueError("报告缺少一级标题,无法写入报告日期")
+
+    lines.insert(heading_index + 1, f"\n{canonical_line}\n")
+    report_path.write_text("".join(lines), encoding="utf-8", newline="")
+    return True
+
+
+def validate_report_risk_semantics(report_path: Path) -> None:
+    """拒绝风险等级与证据状态混用或安全域汇总自相矛盾的报告。
+
+    当前门禁检查两件事:
+
+    1. 安全明细必须包含独立的“证据状态”列;
+    2. 安全域总评为 low 时,明细中不能仍有 medium/high/critical。
+
+    该函数故意不对报告做自动修复,因为应用代码无法安全推断模型遗漏的业务
+    事实;发现矛盾时应中止审批,让生成协议或证据处理逻辑得到修正。
+    """
+    content = report_path.read_text(encoding="utf-8")
+    security_heading = _SECURITY_SECTION_HEADING.search(content)
+    if security_heading is None:
+        raise ValueError("报告缺少安全域风险明细")
+
+    # 截取当前三级标题到下一个三级标题之间的内容,避免把法务或财务表格中的
+    # “证据状态”误当成安全域已经满足要求。
+    next_heading = _LEVEL_THREE_HEADING.search(content, security_heading.end())
+    security_section = (
+        content[security_heading.start():]
+        if next_heading is None
+        else content[security_heading.start():next_heading.start()]
+    )
+    if "证据状态" not in security_section:
+        raise ValueError("安全域风险表必须将风险等级与证据状态分列")
+
+    # 汇总表的第一列存在中英文和“安全域/安全审查”多种写法。这里只寻找安全
+    # 汇总行,第二列才是该专业域的总体风险等级。
+    security_summary = next(
+        (
+            line
+            for line in content.splitlines()
+            if line.lstrip().startswith("|")
+            and (
+                "安全 (Security)" in line
+                or "安全(Security)" in line
+                or re.search(r"\|\s*\**安全(?:域|审查)\**\s*\|", line)
+            )
+        ),
+        "",
+    )
+    summary_cells = [
+        cell.strip().strip("*").strip().lower()
+        for cell in security_summary.strip().strip("|").split("|")
+    ]
+    overall_level = summary_cells[1] if len(summary_cells) > 1 else ""
+    # 聚合冲突只需要在总评为 low 时继续扫描。总评本身已是 medium 以上或
+    # unknown 时,即使明细存在高风险,也不存在“被错误降级为 low”的问题。
+    if overall_level not in {"low", "低"}:
+        return
+
+    blocking_levels = {"medium", "high", "critical", "中", "高", "严重"}
+    detailed_levels: set[str] = set()
+    for line in security_section.splitlines():
+        if not line.lstrip().startswith("|"):
+            continue
+        cells = [
+            cell.strip().strip("*").strip().lower()
+            for cell in line.strip().strip("|").split("|")
+        ]
+        if len(cells) > 1:
+            detailed_levels.add(cells[1])
+
+    conflicts = sorted(detailed_levels & blocking_levels)
+    if conflicts:
+        raise ValueError(
+            "安全域总评为 low,但存在未整改的更高风险项:"
+            + ", ".join(conflicts)
+        )

+ 17 - 0
tests/test_agent_prompt.py

@@ -0,0 +1,17 @@
+"""主管 Agent 执行协议测试。"""
+
+import unittest
+
+from vendor_guard.agent import SUPERVISOR_PROMPT
+
+
+class SupervisorPromptTests(unittest.TestCase):
+    def test_requires_traceable_evidence_paths_in_report(self) -> None:
+        self.assertIn("至少引用 4 个", SUPERVISOR_PROMPT)
+        self.assertIn("/data-room/", SUPERVISOR_PROMPT)
+        self.assertIn("/workspace/findings/", SUPERVISOR_PROMPT)
+        self.assertIn("/policies/", SUPERVISOR_PROMPT)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 100 - 0
tests/test_approval_audit.py

@@ -0,0 +1,100 @@
+"""人工审批审计记录测试。"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+
+from vendor_guard.approval_audit import (
+    ApprovalAuditRecord,
+    append_approval_audit,
+    resolve_report_path,
+)
+
+
+class ApprovalAuditTests(unittest.TestCase):
+    def setUp(self) -> None:
+        self.temp_dir = TemporaryDirectory()
+        self.addCleanup(self.temp_dir.cleanup)
+        self.project_root = Path(self.temp_dir.name)
+        self.report_path = self.project_root / "reports" / "ACME" / "onboarding-report.md"
+        self.report_path.parent.mkdir(parents=True)
+        self.report_path.write_text("# ACME 报告\n", encoding="utf-8")
+
+    def _record(self, *, human_decision: str = "approve") -> ApprovalAuditRecord:
+        status = "提交工具已执行" if human_decision == "approve" else "未执行(人工拒绝)"
+        return ApprovalAuditRecord(
+            record_id="approval-001",
+            vendor_name="ACME",
+            proposed_decision="conditional",
+            human_decision=human_decision,
+            handled_at=datetime(
+                2026,
+                7,
+                23,
+                15,
+                42,
+                18,
+                tzinfo=timezone(timedelta(hours=8)),
+            ),
+            operator="course-user",
+            thread_id="vendor-acme-001",
+            trace_url="https://smith.example/r/trace-id",
+            execution_status=status,
+        )
+
+    def test_resolves_virtual_report_path_inside_project(self) -> None:
+        resolved = resolve_report_path(
+            self.project_root,
+            "/reports/ACME/onboarding-report.md",
+        )
+
+        self.assertEqual(resolved, self.report_path.resolve())
+
+    def test_rejects_path_outside_reports_directory(self) -> None:
+        with self.assertRaisesRegex(ValueError, "reports"):
+            resolve_report_path(self.project_root, "/reports/../.env")
+
+    def test_approved_record_contains_audit_fields(self) -> None:
+        append_approval_audit(self.report_path, self._record())
+
+        report = self.report_path.read_text(encoding="utf-8")
+        self.assertIn("## 人工审批记录", report)
+        self.assertIn("| 建议准入决定 | conditional |", report)
+        self.assertIn("| 人工处理结果 | 同意执行提交 |", report)
+        self.assertIn("2026-07-23T15:42:18+08:00", report)
+        self.assertIn("| 操作人(本机账户,未经身份认证) | course-user |", report)
+        self.assertIn("https://smith.example/r/trace-id", report)
+        self.assertIn("| 提交状态 | 提交工具已执行 |", report)
+
+    def test_rejected_record_says_submission_was_not_executed(self) -> None:
+        self.report_path.write_text(
+            "# ACME 报告\n\n"
+            "> ⏳ **本报告已提交人工审批,审批结果将由 CLI 写入。**\n",
+            encoding="utf-8",
+        )
+        append_approval_audit(
+            self.report_path,
+            self._record(human_decision="reject"),
+        )
+
+        report = self.report_path.read_text(encoding="utf-8")
+        self.assertIn("| 人工处理结果 | 拒绝执行提交 |", report)
+        self.assertIn("| 提交状态 | 未执行(人工拒绝) |", report)
+        self.assertIn("人工审批已完成:拒绝执行提交", report)
+        self.assertNotIn("审批结果将", report)
+
+    def test_same_record_is_not_appended_twice(self) -> None:
+        record = self._record()
+
+        self.assertTrue(append_approval_audit(self.report_path, record))
+        self.assertFalse(append_approval_audit(self.report_path, record))
+
+        report = self.report_path.read_text(encoding="utf-8")
+        self.assertEqual(report.count("## 人工审批记录"), 1)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 96 - 0
tests/test_langsmith_evaluation.py

@@ -0,0 +1,96 @@
+"""LangSmith 离线评估隔离测试。"""
+
+import io
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+from contextlib import redirect_stdout
+from unittest.mock import patch
+
+from vendor_guard.langsmith_evaluation import (
+    _evaluate_without_sdk_console,
+    _isolated_evaluation_workspace,
+)
+
+
+class EvaluationWorkspaceTests(unittest.TestCase):
+    def setUp(self) -> None:
+        self.temp_dir = TemporaryDirectory()
+        self.addCleanup(self.temp_dir.cleanup)
+        self.source_root = Path(self.temp_dir.name)
+        for directory in ("data-room", "policies", "skills", "fixtures"):
+            path = self.source_root / directory
+            path.mkdir()
+            (path / "source.txt").write_text(directory, encoding="utf-8")
+
+        old_report = (
+            self.source_root
+            / "reports"
+            / "ACME"
+            / "onboarding-report.md"
+        )
+        old_report.parent.mkdir(parents=True)
+        old_report.write_text("旧的交互报告", encoding="utf-8")
+
+    def test_workspace_copies_inputs_but_not_existing_reports(self) -> None:
+        with _isolated_evaluation_workspace(self.source_root) as workspace:
+            self.assertEqual(
+                (workspace / "data-room" / "source.txt").read_text(
+                    encoding="utf-8"
+                ),
+                "data-room",
+            )
+            self.assertTrue((workspace / "policies" / "source.txt").is_file())
+            self.assertTrue((workspace / "skills" / "source.txt").is_file())
+            self.assertTrue((workspace / "fixtures" / "source.txt").is_file())
+            self.assertTrue((workspace / "reports").is_dir())
+            self.assertFalse(
+                (workspace / "reports" / "ACME" / "onboarding-report.md").exists()
+            )
+
+    def test_workspace_changes_do_not_touch_real_project(self) -> None:
+        with _isolated_evaluation_workspace(self.source_root) as workspace:
+            sandbox_policy = workspace / "policies" / "source.txt"
+            sandbox_policy.write_text("被测试用例修改", encoding="utf-8")
+            sandbox_report = (
+                workspace / "reports" / "ACME" / "onboarding-report.md"
+            )
+            sandbox_report.parent.mkdir(parents=True)
+            sandbox_report.write_text("新评估报告", encoding="utf-8")
+
+        self.assertEqual(
+            (self.source_root / "policies" / "source.txt").read_text(
+                encoding="utf-8"
+            ),
+            "policies",
+        )
+        self.assertEqual(
+            (
+                self.source_root
+                / "reports"
+                / "ACME"
+                / "onboarding-report.md"
+            ).read_text(encoding="utf-8"),
+            "旧的交互报告",
+        )
+
+    @patch("vendor_guard.langsmith_evaluation.evaluate")
+    def test_sdk_english_output_and_progress_are_hidden(self, evaluate) -> None:
+        expected_result = object()
+
+        def noisy_evaluate(_target, **_kwargs):
+            print("View the evaluation results for experiment")
+            return expected_result
+
+        evaluate.side_effect = noisy_evaluate
+        visible_stdout = io.StringIO()
+
+        with redirect_stdout(visible_stdout):
+            result = _evaluate_without_sdk_console(object(), data="dataset")
+
+        self.assertIs(result, expected_result)
+        self.assertEqual(visible_stdout.getvalue(), "")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 57 - 0
tests/test_report_lifecycle.py

@@ -0,0 +1,57 @@
+"""供应商报告运行隔离测试。"""
+
+from datetime import datetime, timezone
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+
+from vendor_guard.report_metadata import archive_existing_report
+
+
+class ReportLifecycleTests(unittest.TestCase):
+    def setUp(self) -> None:
+        self.temp_dir = TemporaryDirectory()
+        self.addCleanup(self.temp_dir.cleanup)
+        self.project_root = Path(self.temp_dir.name)
+        self.report_path = (
+            self.project_root / "reports" / "ACME" / "onboarding-report.md"
+        )
+        self.report_path.parent.mkdir(parents=True)
+
+    def test_archives_existing_report_before_new_run(self) -> None:
+        self.report_path.write_text(
+            "# 旧报告\n\n## 人工审批记录\n\n拒绝执行提交\n",
+            encoding="utf-8",
+        )
+
+        archived_path = archive_existing_report(
+            self.project_root,
+            self.report_path,
+            datetime(2026, 7, 23, 18, 30, tzinfo=timezone.utc),
+        )
+
+        self.assertIsNotNone(archived_path)
+        assert archived_path is not None
+        self.assertFalse(self.report_path.exists())
+        self.assertTrue(archived_path.is_file())
+        self.assertEqual(
+            archived_path.parent,
+            self.project_root / "report-history" / "ACME",
+        )
+        self.assertIn(
+            "拒绝执行提交",
+            archived_path.read_text(encoding="utf-8"),
+        )
+
+    def test_returns_none_when_no_previous_report_exists(self) -> None:
+        archived_path = archive_existing_report(
+            self.project_root,
+            self.report_path,
+            datetime(2026, 7, 23, 18, 30, tzinfo=timezone.utc),
+        )
+
+        self.assertIsNone(archived_path)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 126 - 0
tests/test_report_metadata.py

@@ -0,0 +1,126 @@
+"""可信报告元数据测试。"""
+
+from datetime import date
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+
+from vendor_guard.report_metadata import (
+    stamp_report_date,
+    validate_report_risk_semantics,
+)
+
+
+class ReportMetadataTests(unittest.TestCase):
+    def setUp(self) -> None:
+        self.temp_dir = TemporaryDirectory()
+        self.addCleanup(self.temp_dir.cleanup)
+        self.report_path = Path(self.temp_dir.name) / "report.md"
+
+    def test_replaces_model_generated_report_date(self) -> None:
+        self.report_path.write_text(
+            "# ACME 报告\n\n**报告日期:2026-05-12**\n",
+            encoding="utf-8",
+        )
+
+        changed = stamp_report_date(self.report_path, date(2026, 7, 23))
+
+        self.assertTrue(changed)
+        report = self.report_path.read_text(encoding="utf-8")
+        self.assertIn("**报告日期:** 2026-07-23", report)
+        self.assertNotIn("2026-05-12", report)
+
+    def test_inserts_date_when_model_omits_it(self) -> None:
+        self.report_path.write_text("# ACME 报告\n\n正文\n", encoding="utf-8")
+
+        stamp_report_date(self.report_path, date(2026, 7, 23))
+
+        report = self.report_path.read_text(encoding="utf-8")
+        self.assertTrue(
+            report.startswith("# ACME 报告\n\n**报告日期:** 2026-07-23")
+        )
+
+    def test_canonical_date_is_idempotent(self) -> None:
+        self.report_path.write_text(
+            "# ACME 报告\n\n**报告日期:** 2026-07-23\n",
+            encoding="utf-8",
+        )
+
+        self.assertFalse(
+            stamp_report_date(self.report_path, date(2026, 7, 23))
+        )
+
+    def test_requires_separate_evidence_status_column(self) -> None:
+        self.report_path.write_text(
+            "# 报告\n\n### 安全域\n\n| 风险项 | 等级 | 证据位置 |\n",
+            encoding="utf-8",
+        )
+
+        with self.assertRaisesRegex(ValueError, "证据状态"):
+            validate_report_risk_semantics(self.report_path)
+
+    def test_rejects_low_domain_with_medium_detail(self) -> None:
+        self.report_path.write_text(
+            "# 报告\n\n"
+            "| 专业域 | 风险等级 |\n"
+            "|---|---|\n"
+            "| **安全 (Security)** | **LOW** |\n\n"
+            "### 安全域\n\n"
+            "| 风险项 | 风险等级 | 证据状态 |\n"
+            "|---|---|---|\n"
+            "| PT-02 | Medium | 已验证 |\n",
+            encoding="utf-8",
+        )
+
+        with self.assertRaisesRegex(ValueError, "总评为 low"):
+            validate_report_risk_semantics(self.report_path)
+
+    def test_accepts_medium_domain_with_separated_evidence_status(self) -> None:
+        self.report_path.write_text(
+            "# 报告\n\n"
+            "| 专业域 | 风险等级 |\n"
+            "|---|---|\n"
+            "| **安全 (Security)** | **MEDIUM** |\n\n"
+            "### 安全域\n\n"
+            "| 风险项 | 风险等级 | 证据状态 |\n"
+            "|---|---|---|\n"
+            "| PT-02 | Medium | 已验证 |\n"
+            "| MFA | Low | 自述待验证 |\n",
+            encoding="utf-8",
+        )
+
+        validate_report_risk_semantics(self.report_path)
+
+    def test_accepts_numbered_security_domain_heading(self) -> None:
+        self.report_path.write_text(
+            "# 报告\n\n"
+            "| 专业域 | 风险等级 |\n"
+            "|---|---|\n"
+            "| **安全 (Security)** | **MEDIUM** |\n\n"
+            "### 2.1 安全域 — 风险等级:medium\n\n"
+            "| 风险项 | 风险等级 | 证据状态 |\n"
+            "|---|---|---|\n"
+            "| PT-02 | Medium | 已验证 |\n",
+            encoding="utf-8",
+        )
+
+        validate_report_risk_semantics(self.report_path)
+
+    def test_accepts_numbered_security_review_heading(self) -> None:
+        self.report_path.write_text(
+            "# UNKNOWN 报告\n\n"
+            "| 专业域 | 风险等级 |\n"
+            "|---|---|\n"
+            "| 安全审查 | high |\n\n"
+            "### 3.1 安全审查(Security Review)\n\n"
+            "| 风险领域 | 风险等级 | 证据状态 |\n"
+            "|---|---|---|\n"
+            "| 身份与访问控制 | unknown | 缺失 |\n",
+            encoding="utf-8",
+        )
+
+        validate_report_risk_semantics(self.report_path)
+
+
+if __name__ == "__main__":
+    unittest.main()