From 28e4ddbfdfb8b7ca229282dfad54805eea23efef Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 12 Aug 2026 06:34:00 +0800 Subject: [PATCH] feat: prepare public policy legal review package --- ...licy-offline-signing-runbook-2026-08-12.md | 96 +++ .../2026-08-12/README.md | 7 + .../2026-08-12/ai-use-notice.md | 79 +++ .../2026-08-12/manifest.json | 42 ++ .../2026-08-12/privacy-policy.md | 98 +++ .../2026-08-12/service-agreement.md | 93 +++ .../2026-08-12/user-terms.md | 83 +++ .../scripts/prepare-policy-legal-approval.py | 601 ++++++++++++++++++ .../tests/test_policy_legal_signing_helper.py | 195 ++++++ 9 files changed, 1294 insertions(+) create mode 100644 project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md create mode 100644 project-docs/legal-policy-candidates/2026-08-12/README.md create mode 100644 project-docs/legal-policy-candidates/2026-08-12/ai-use-notice.md create mode 100644 project-docs/legal-policy-candidates/2026-08-12/manifest.json create mode 100644 project-docs/legal-policy-candidates/2026-08-12/privacy-policy.md create mode 100644 project-docs/legal-policy-candidates/2026-08-12/service-agreement.md create mode 100644 project-docs/legal-policy-candidates/2026-08-12/user-terms.md create mode 100644 yuqei-infra/scripts/prepare-policy-legal-approval.py create mode 100644 yuqei-infra/tests/test_policy_legal_signing_helper.py diff --git a/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md b/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md new file mode 100644 index 00000000..d2f8b5b2 --- /dev/null +++ b/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md @@ -0,0 +1,96 @@ +# V2 公开政策离线签批操作手册 + +日期:2026-08-12 + +## 边界 + +本流程只自动完成精确文本绑定、Ed25519 签名和验签,不替代具名审批人的法律判断。候选文本、未签名 payload、候选 keyring 或技术测试通过均不表示政策已经获得批准或已经生效。 + +加密私钥和密码必须始终保存在审批人控制的本机,不得进入仓库、`.runtime`、聊天、邮件、共享预发布、生产服务器或 CI artifact。生产和共享预发布只安装经过独立确认的公钥 keyring。 + +## 当前候选包 + +- 运营者:内蒙古钰麒科技有限公司 +- 统一社会信用代码:`91150203MA7YPGQ49E` +- 审批人:陈钰麒 +- approver ID:`cyuqei@gmail.com` +- 职务:CEO +- policy key ID:`policy-legal-2026q3` +- 公钥文件 SHA-256:`a4bd58fd010965dc1a5911454b67a720c34c5d5c6a7510bd30e94ddd7dc16739` +- 正式版本:`2026-08-12` +- 计划生效时间:`2026-08-13T00:00:00+08:00` +- 既有用户:下次登录必须重新同意 +- 单次审批有效窗口:最长 7 天 + +候选文本位于 `project-docs/legal-policy-candidates/2026-08-12/`。当前精确文件绑定: + +| policy_id | 文件 | SHA-256 | +| --- | --- | --- | +| `user_terms` | `user-terms.md` | `8986db816ec97b221c5df05d5bb8f2eabdc14d6fc6aceab46d7595b536d59989` | +| `privacy_policy` | `privacy-policy.md` | `1d60f39d7b50aac6613ab85dc97f58aad1877248272f0cce9b56db8711ea98f6` | +| `service_agreement` | `service-agreement.md` | `214f5744039dc8ebf554f6ead06e5a9fb3ec08ea973719bb59fc8e3f8b1d5f48` | +| `ai_use_notice` | `ai-use-notice.md` | `f261df70a29109e90a537f5812d871591e4787716e008d7966ed9b5e79ebcdfd` | + +当前未签名材料位于 `.runtime/production-closure/policies/2026-08-12/`: + +- review package SHA-256:`0b3c105feaf5025e20b6799701ec19b8580837ff2ad71297baf96af56a1d8e62` +- pending payload SHA-256:`7f543f8980ceedc085d451c6c8d691d5ba93fa5f11baef276f4bd004a1b4eba5` +- candidate keyring SHA-256:`1f85a758252d571aec4233c864714ad2fb637940ebb2f0d2e37d1818b09f9df4` + +这三个文件当前状态分别是 `pending_named_legal_review` 或 `candidate_not_trusted`,不得签名、发布或安装为生产信任材料。 + +## 审批人审阅清单 + +审批人必须逐份审阅四个 Markdown 文件,并确认: + +1. 公司主体、注册地址、统一社会信用代码和联系邮箱准确; +2. 数据种类、处理目的、AI Provider、共享、跨境、保存期限和用户权利与实际产品及运营一致; +3. 用户协议、服务范围、费用、知识产权、责任边界和数据退出规则可接受; +4. AI 输出边界、人工复核、高风险事项和禁止用途可接受; +5. 法律适用、争议管辖、版本、生效时间和重新同意策略准确; +6. review package、pending payload、公钥和四份文件的 SHA-256 与本手册一致。 + +任何文本、空白、标点、编码或审批字段发生变化,必须重新运行 `prepare` 并重新审阅全部新 hash。 + +## 生成最终批准 payload + +只有在具名审批人完成上述审阅并明确批准当前 pending payload 后,才运行: + +```powershell +python yuqei-infra/scripts/prepare-policy-legal-approval.py finalize-payload ` + --pending-payload-file .runtime/production-closure/policies/2026-08-12/policy-approval-pending-payload.json ` + --approved-at ` + --expires-at ` + --output .runtime/production-closure/policies/2026-08-12/policy-approval-final-payload.json +``` + +记录命令输出的最终 payload SHA-256。该 hash 仍未签名,审批人必须再次核对批准时间、失效时间和 exact hash。 + +## 离线签名 + +审批人在本机创建仅本人可读的一行密码文件,文件不得位于仓库。然后运行: + +```powershell +python yuqei-infra/scripts/prepare-policy-legal-approval.py sign ` + --payload-file .runtime/production-closure/policies/2026-08-12/policy-approval-final-payload.json ` + --private-key-file ` + --private-key-password-file ` + --confirmation I_APPROVE_THIS_EXACT_POLICY_PAYLOAD ` + --output .runtime/production-closure/policies/2026-08-12/named-policy-legal-approval.json +``` + +签名命令会拒绝错误确认字符串、非最终 payload、错误密码、非 Ed25519 私钥或与 payload 公钥绑定不一致的私钥。 + +## 验签与生产边界 + +候选 keyring 不能直接作为生产信任。安全或运维负责人应核对公钥来源和审批人身份,将状态从 `candidate_not_trusted` 独立提升为 `trusted`,并保留安装证据。随后运行: + +```powershell +python yuqei-infra/scripts/prepare-policy-legal-approval.py verify ` + --approval-file .runtime/production-closure/policies/2026-08-12/named-policy-legal-approval.json ` + --keyring-file ` + --policy-dir project-docs/legal-policy-candidates/2026-08-12 ` + --checked-at +``` + +只有 `status=verified` 才允许进入后续代码阶段:将四份公开页面切换为已批准字节、将后端协议目录切换为 `effective`、记录历史版本、强制既有用户下次登录重新同意,并完成预发布及生产只读回归。验签失败、审批过期、keyring 未信任或任一文件 hash 不一致时必须保持线上草案和公网 No-Go。 diff --git a/project-docs/legal-policy-candidates/2026-08-12/README.md b/project-docs/legal-policy-candidates/2026-08-12/README.md new file mode 100644 index 00000000..d7812e79 --- /dev/null +++ b/project-docs/legal-policy-candidates/2026-08-12/README.md @@ -0,0 +1,7 @@ +# 公开政策候选包 2026-08-12 + +本目录包含拟发布的四份公开政策精确文本。目录内文件目前仅用于具名法务审阅,尚未签名、尚未生效、尚未替换线上 `2026-08-01-draft`。 + +任何文字、标点、空白或编码变化都会改变文件 SHA-256。法务批准必须绑定 `manifest.json`、四份 Markdown 原始字节和 policy review package 的精确 hash。 + +批准后仍需完成离线验签、trusted keyring 安装、后端协议目录切换、公开页面逐字节核对、既有用户下次登录重新同意和生产门禁验证,才可以标记为正式生效。 diff --git a/project-docs/legal-policy-candidates/2026-08-12/ai-use-notice.md b/project-docs/legal-policy-candidates/2026-08-12/ai-use-notice.md new file mode 100644 index 00000000..af6bbe0e --- /dev/null +++ b/project-docs/legal-policy-candidates/2026-08-12/ai-use-notice.md @@ -0,0 +1,79 @@ +# AI 使用与风险提示 + +版本:2026-08-12 + +生效时间:2026年8月13日 00:00(北京时间) + +提供方:内蒙古钰麒科技有限公司 + +## 一、AI 功能定位 + +1. 平台 AI 功能可用于合同起草、风险识别、条款推荐、内容改写、法律知识问答、摘要和报告生成。 +2. AI 输出是基于模型、提示、知识资料和用户输入生成的辅助信息,不构成律师出具的正式法律意见,不建立律师与客户关系,也不代表法院、仲裁机构、监管部门或任何专业机构的立场。 +3. AI 不能代替用户了解完整事实、核对证据、识别商业目标并作出最终决定。 + +## 二、已知限制 + +AI 输出可能存在下列限制: + +1. 遗漏风险、错误理解上下文、主体、金额、期限或交易结构; +2. 生成不存在、已失效、被修改或不适用的法律依据、案例、机构或引用; +3. 无法及时反映最新法律法规、司法解释、地方规则、监管口径和行业实践; +4. 对不同地域、行业、合同类型和争议事实的适用性判断不准确; +5. 相同或相似输入产生不同结果,或与其他用户获得的通用表达相似; +6. 受输入质量、文档解析、模型能力、Provider 状态和系统故障影响。 + +页面显示的引用、置信度、风险级别和建议仅用于辅助判断,不应被视为准确性保证。 + +## 三、必须人工复核 + +1. 用户应核对合同主体、授权、事实、金额、税费、期限、履行条件、违约责任、争议条款、法律依据和最终排版。 +2. 任何将被签署、提交法院或仲裁机构、报送监管机关、用于重大内部审批或对个人权益产生重大影响的材料,必须由具有相应权限和专业能力的人员复核。 +3. 对存在冲突、低置信度、缺少依据、引用不可访问或提示信息不足的结果,应停止自动采用并进行独立核验。 + +## 四、高风险事项 + +下列事项不得仅依据 AI 输出作出决定,建议及时咨询执业律师、会计师、税务师或其他适格专业人员: + +1. 诉讼、仲裁、执行、行政处罚和刑事风险; +2. 重大投融资、股权、并购、担保、融资和资产处置; +3. 劳动争议、批量人员调整、职业健康和人事敏感信息; +4. 数据跨境、国家秘密、重要数据、网络安全审查和监管许可; +5. 医疗、金融、保险、证券、未成年人或其他受严格监管事项; +6. 可能造成重大财产、人身、声誉或公共利益影响的其他事项。 + +## 五、输入数据与 Provider 处理 + +1. 请仅提交完成任务所必要的信息,并在可能情况下删除或遮蔽身份证号、银行账号、健康信息、未成年人信息、商业秘密和其他敏感内容。 +2. 您应确保有权向平台和 AI Provider 提交相关信息,并已完成必要的告知、同意、授权或其他合法处理依据。 +3. 平台可能向经审批的动态 AI Provider 发送完成请求所必要的文本、提示和上下文,并通过最小化、访问控制、超时、审计和禁用生产 fallback 等措施控制处理。 +4. 未经您的单独授权,我们不会将合同、附件、提示或专有业务内容用于训练面向其他客户的通用模型。 +5. 个人信息处理规则、Provider 变化和跨境门禁以同版本隐私政策为准。 + +## 六、禁止用途 + +不得利用 AI 功能: + +1. 生成、传播或实施违法、欺诈、侵权、骚扰、歧视或恶意活动; +2. 冒充律师、司法机关、监管机关或其他专业主体; +3. 伪造证据、签名、授权、合同或审计记录; +4. 绕过平台权限、配额、安全、审计或内容控制; +5. 在没有法律依据和人工复核的情况下,对个人作出具有重大影响的自动化决定; +6. 将输出宣称为经钰麒科技、律师或政府机构正式审核、认证或保证的结论。 + +## 七、用户责任与反馈 + +1. 用户负责判断输入是否合法、输出是否适用以及是否采纳,并对最终合同和业务决定承担相应责任;平台存在过错的,依法承担责任。 +2. 发现错误引用、重大遗漏、敏感信息暴露或安全问题时,请停止使用相关输出并通过 admin@yuqei.com 联系我们。 +3. 对个人信息处理的投诉或权利请求,请发送至 chenyong@yuqei.com。 + +## 八、服务变化与责任边界 + +1. 模型、Provider、提示词、知识库和质量门禁可能根据技术、安全和合规评估调整。重大变化将依法或依约告知。 +2. 我们不保证 AI 输出绝对准确、完整、实时、唯一或适用于所有目的,但将采取合理措施开展质量测试、故障降级、安全审计和问题改进。 +3. 本提示不排除或限制依法不得排除、限制的责任。其他责任按照用户协议、服务协议、订单和适用法律承担。 + +## 九、版本、法律适用与争议解决 + +1. 本提示版本为 2026-08-12,自 2026年8月13日 00:00(北京时间)起生效。既有用户应在生效后的下次登录时重新阅读并明确同意。 +2. 本提示适用中华人民共和国法律(为本提示之目的,不包括冲突法规则)。争议协商不成的,可向内蒙古自治区包头市昆都仑区有管辖权的人民法院提起诉讼。 diff --git a/project-docs/legal-policy-candidates/2026-08-12/manifest.json b/project-docs/legal-policy-candidates/2026-08-12/manifest.json new file mode 100644 index 00000000..5a029cf1 --- /dev/null +++ b/project-docs/legal-policy-candidates/2026-08-12/manifest.json @@ -0,0 +1,42 @@ +{ + "schema_version": "yuqei-public-policy-candidate-v1", + "version": "2026-08-12", + "effective_at": "2026-08-13T00:00:00+08:00", + "publisher": { + "legal_name": "内蒙古钰麒科技有限公司", + "unified_social_credit_code": "91150203MA7YPGQ49E", + "registered_address": "内蒙古自治区包头市昆都仑区白云路与黄河大街交汇处南200米恒大名都三期G-B517", + "public_contact_email": "admin@yuqei.com", + "privacy_contact_email": "chenyong@yuqei.com" + }, + "governing_law": "中华人民共和国法律(为本协议之目的,不包括冲突法规则)", + "dispute_resolution": "内蒙古自治区包头市昆都仑区有管辖权的人民法院", + "existing_user_reconsent": "required_on_next_login", + "approval_window_days": 7, + "documents": [ + { + "policy_id": "user_terms", + "title": "用户协议", + "public_path": "/terms", + "source_file": "user-terms.md" + }, + { + "policy_id": "privacy_policy", + "title": "隐私政策", + "public_path": "/privacy", + "source_file": "privacy-policy.md" + }, + { + "policy_id": "service_agreement", + "title": "服务协议", + "public_path": "/service-agreement", + "source_file": "service-agreement.md" + }, + { + "policy_id": "ai_use_notice", + "title": "AI 使用与风险提示", + "public_path": "/ai-disclaimer", + "source_file": "ai-use-notice.md" + } + ] +} diff --git a/project-docs/legal-policy-candidates/2026-08-12/privacy-policy.md b/project-docs/legal-policy-candidates/2026-08-12/privacy-policy.md new file mode 100644 index 00000000..01bc862b --- /dev/null +++ b/project-docs/legal-policy-candidates/2026-08-12/privacy-policy.md @@ -0,0 +1,98 @@ +# 隐私政策 + +版本:2026-08-12 + +生效时间:2026年8月13日 00:00(北京时间) + +个人信息处理者:内蒙古钰麒科技有限公司 + +## 一、适用范围与联系方式 + +1. 本政策适用于钰麒合同平台网站、应用及相关服务对个人信息的处理。 +2. 个人信息处理者:内蒙古钰麒科技有限公司,统一社会信用代码 91150203MA7YPGQ49E。 +3. 注册地址:内蒙古自治区包头市昆都仑区白云路与黄河大街交汇处南200米恒大名都三期G-B517。 +4. 个人信息保护、投诉与权利请求邮箱:chenyong@yuqei.com;一般联系邮箱:admin@yuqei.com。 +5. 组织工作空间中,组织可能依法独立决定其成员和业务数据的处理目的、方式。对于组织受托我们处理的数据,相关组织是个人信息处理者,我们按照双方约定处理。 + +## 二、处理的个人信息 + +根据您使用的功能,我们可能处理: + +1. 账号信息:姓名或显示名称、邮箱、手机号(如提供)、密码摘要、账号状态和认证记录; +2. 组织与权限信息:公司或组织名称、部门、职务、工作空间成员关系、角色、授权和邀请记录; +3. 合同与业务内容:合同正文、附件、合同相对方、联系人、签署主体、审批意见、合同金额、履行信息及您主动输入的其他业务资料; +4. AI 交互信息:向 AI 功能提交的文本、问题、选定资料、提示、生成结果、引用、质量反馈和运行记录; +5. 文件与知识资产:上传文件、文件名、类型、大小、解析内容、模板、条款、规则和知识文档; +6. 设备与日志信息:IP 地址、浏览器和设备信息、登录时间、访问路径、操作日志、错误日志、安全事件、trace_id 和审计记录; +7. 服务与交易信息:套餐、订单、开票和支付状态、售后与投诉沟通记录;支付卡号等信息通常由合规支付机构直接处理,我们不主动存储完整支付卡信息; +8. 您主动提供或依法授权第三方提供的其他信息。 + +合同可能包含身份证件、联系方式、银行账号、健康、劳动、人事、未成年人信息或其他敏感个人信息。请仅在确有必要、具有合法处理依据并完成必要告知与授权的情况下上传。 + +## 三、处理目的与依据 + +我们可能基于订立或履行合同所必需、履行法定义务、取得个人同意或法律允许的其他依据,为下列目的处理个人信息: + +1. 创建账号、完成身份认证、管理个人或组织工作空间; +2. 提供合同起草、存储、检索、审查、审批、报告和协作功能; +3. 调用经审批的 AI Provider 完成您主动请求的模型推理; +4. 记录协议同意、操作审计、权限变更、安全事件和服务交付证据; +5. 防范欺诈、攻击、越权、滥用和其他安全风险; +6. 处理咨询、投诉、故障、订单、开票和客户服务; +7. 遵守法律法规、监管要求及有权机关的合法要求。 + +如处理目的、方式或个人信息种类发生实质变化,我们将依法重新告知并在需要时取得同意。对敏感个人信息、向其他处理者提供信息、公开披露或跨境提供等依法需要单独同意的事项,我们将另行取得单独同意。 + +## 四、AI Provider 与受托处理 + +1. 为响应您主动发起的 AI 请求,平台可能向经技术、安全和合规审批的动态 AI Provider 发送完成该请求所必要的合同片段、问题、提示和上下文。 +2. 我们通过访问控制、最小化传输、超时、审计、质量门禁和禁用生产 fallback 等措施约束 Provider 调用。具体 Provider 可能随质量、安全和合规评估变化。 +3. 未经您的单独授权,我们不会将您的合同正文、附件、提示或专有业务内容用于训练面向其他客户的通用模型,并将通过合同或技术措施要求受托处理者不得擅自改变处理目的。 +4. 如 Provider 的主体、处理地点或处理规则发生可能对个人权益产生重大影响的变化,我们将依法更新说明并履行相应告知、同意或个人信息保护影响评估义务。 + +## 五、共享、转移、公开披露和有权机关要求 + +1. 我们不会出售个人信息。 +2. 为提供云计算、对象存储、消息、邮件、AI 推理、支付、审计或安全服务,我们可能委托必要的服务商处理信息,并通过协议和安全措施限定目的、期限、方式和安全责任。 +3. 因合并、分立、重组、资产转让或破产等原因需要转移个人信息的,我们将依法告知接收方信息,并要求接收方继续履行本政策或重新取得同意。 +4. 除取得单独同意或法律另有规定外,我们不会公开披露个人信息。 +5. 对有权机关依法提出的要求,我们将在核验权限和范围后依法提供必要信息,并保留审计记录。 + +## 六、跨境处理 + +平台默认优先在中华人民共和国境内处理和存储生产数据。确需向境外提供个人信息的,我们将在实施前明确告知境外接收方、处理目的、方式、信息种类和权利行使方式,完成法律要求的安全评估、认证、标准合同或其他程序,并依法取得单独同意。未完成相应门禁前,不启用相关跨境处理。 + +## 七、保存期限与删除 + +我们仅在实现处理目的所必要的最短期限内保存个人信息,并执行以下一般规则;订单、数据处理协议或法律另有更长要求的,从其规定: + +1. 账号、组织和权限信息:在账号或组织服务存续期间保存;账号注销或服务终止后,一般在三十日导出窗口届满后删除或匿名化,依法需要处理争议、安全或审计事项的必要记录除外; +2. 合同、附件、知识资产和 AI 结果:由有权用户或组织管理员管理;删除请求完成后从在线业务系统删除,并在不超过三十日的滚动备份清除周期内从备份中自然清除,依法留存或恢复演练所需的隔离备份除外; +3. 登录、网络和安全日志:保存不少于六个月;为调查重大安全事件确需延长的,仅在必要范围内保存; +4. 协议同意、权限、审计和争议处理记录:一般保存三年,法律法规、监管要求或诉讼时效要求更长期限的,从其规定; +5. 交易、发票和财务记录:按照税务、会计等法律规定的期限保存; +6. 临时文件和失败任务材料:在完成处理、排障或安全核验后尽快删除。 + +保存期限届满后,我们将删除或匿名化个人信息。匿名化后的信息不能识别特定个人且不能复原的,不再属于个人信息。 + +## 八、安全措施与事件处置 + +1. 我们采取工作空间隔离、最小权限、加密传输、受保护密钥、日志审计、备份恢复、漏洞管理和访问控制等措施保护信息。 +2. support access、身份执行器和破坏性身份写接口在首发范围内保持关闭;未来启用必须另行通过身份、MFA、会话绑定、撤销、审计和隐私法务门禁。 +3. 如发生或可能发生个人信息泄露、篡改、丢失,我们将立即采取补救措施,并依法通知履行个人信息保护职责的部门和相关个人;法律规定可以不通知的除外。 + +## 九、您的权利 + +您可依法请求查阅、复制、更正、补充、删除个人信息,撤回基于同意的授权,注销账号,获取处理规则说明,或对自动化决策结果提出解释和拒绝仅由自动化决策作出对个人权益有重大影响的决定。 + +组织工作空间中的业务数据请求可能需要由所属组织管理员核验和处理。为保障安全,我们可能核验请求人身份。一般情况下,我们将在十五个工作日内答复;情况复杂或法律另有规定的,将说明延期原因。 + +## 十、未成年人信息 + +平台主要面向具有完全民事行为能力的个人及组织用户,不主动面向未满十四周岁的儿童提供服务。如确需处理未成年人信息,上传者应确保具有合法依据并完成监护人同意等要求。发现未经适当授权处理儿童信息时,请通过隐私邮箱联系我们,我们将及时核验和处置。 + +## 十一、政策更新、法律适用与争议解决 + +1. 本政策版本为 2026-08-12,自 2026年8月13日 00:00(北京时间)起生效。既有用户应在生效后的下次登录时重新阅读并明确同意。 +2. 对处理目的、敏感信息、共享对象、跨境处理或个人权利产生重大影响的更新,我们将显著通知并依法重新取得同意。 +3. 本政策适用中华人民共和国法律(为本政策之目的,不包括冲突法规则)。争议协商不成的,可向内蒙古自治区包头市昆都仑区有管辖权的人民法院提起诉讼。 diff --git a/project-docs/legal-policy-candidates/2026-08-12/service-agreement.md b/project-docs/legal-policy-candidates/2026-08-12/service-agreement.md new file mode 100644 index 00000000..67b5f7c9 --- /dev/null +++ b/project-docs/legal-policy-candidates/2026-08-12/service-agreement.md @@ -0,0 +1,93 @@ +# 服务协议 + +版本:2026-08-12 + +生效时间:2026年8月13日 00:00(北京时间) + +服务提供方:内蒙古钰麒科技有限公司 + +## 一、协议构成与适用顺序 + +1. 本协议适用于钰麒科技向个人用户、企业及其他组织提供的钰麒合同平台服务。 +2. 本协议与用户协议、隐私政策、AI 使用与风险提示、订单、报价单、数据处理协议及双方签署的补充协议共同构成服务约定。 +3. 文件约定不一致时,双方签署的专项补充协议、数据处理协议或订单优先于本协议;本协议优先于一般产品说明,但法律强制性规定除外。 + +## 二、服务范围 + +1. 服务可能包括合同模板起草、文件管理、AI 审查、条款推荐、报告下载、审批协作、知识资产、审计和管理功能,具体以订单和实际开放能力为准。 +2. 部分功能依赖网络、云基础设施、AI Provider、邮件、对象存储或其他第三方服务。我们将选择适当服务商并进行必要的安全和合规管理。 +3. 客户可根据订单获得个人或组织工作空间、成员数量、存储、AI 用量和其他配额。超出配额时,平台可限制新增操作并提示升级,不会在未说明的情况下自动产生收费。 + +## 三、开通、管理员与授权 + +1. 首发服务可采用管理员线下开通或一次性邀请方式。组织应指定管理员并确保其有权管理成员、角色、权限和组织数据。 +2. 组织管理员作出的成员邀请、权限授予、停用和工作空间配置视为组织授权操作,但平台明知或应知存在越权、欺诈或安全风险的除外。 +3. 客户应及时停用离职、调岗或失去授权的成员账号,并定期复核权限。 + +## 四、试用、费用与发票 + +1. 试用范围、期限和配额以页面或书面通知为准。试用到期后,平台可停止受限功能,但应依法和依约提供合理的数据处理安排。 +2. 付费服务的价格、付款周期、税费、续费和退款规则以订单、报价单或双方书面约定为准。未经客户明确确认,不自动将免费服务转换为付费订单。 +3. 客户应按约定支付费用。逾期且经合理催告仍未支付的,我们可暂停相应付费功能,但应避免不必要地扩大对数据导出和依法行使个人权利的影响。 +4. 发票按照订单和适用税务规则开具。 + +## 五、服务可用性、维护与支持 + +1. 我们将采取商业上合理的措施维持服务稳定、安全和可用。特定可用性比例、响应时间、恢复时间目标或赔偿标准仅在订单或专项 SLA 明确约定时适用。 +2. 计划维护将尽可能提前通知。为处置重大漏洞、攻击、故障或监管要求进行的紧急维护,可以先行处置并在合理时间内补充说明。 +3. 一般支持通过 admin@yuqei.com 提供。订单约定专属支持渠道、服务时间或响应级别的,从其约定。 +4. 客户提交故障时应提供必要且经过最小化处理的复现信息。support access 在首发范围内关闭;如未来启用,必须另行取得明确授权并通过安全和隐私门禁。 + +## 六、数据、备份与恢复 + +1. 客户对其用户内容及业务数据保留依法享有的权利,并负责保证数据来源和处理授权合法。 +2. 我们按照隐私政策和数据处理协议处理数据,并采取工作空间隔离、访问控制、日志、备份和恢复措施。 +3. 平台备份用于服务灾难恢复,不替代客户自身的业务归档义务。客户应对关键合同和最终签署文件保留独立副本。 +4. 备份频率、保留期限、恢复目标和演练要求以订单或运维政策为准;平台默认采用滚动备份并定期验证恢复能力。 + +## 七、保密与安全 + +1. 双方对在合作中知悉的商业秘密、合同内容、技术资料和其他明确或依性质应当保密的信息承担保密义务。 +2. 接收方仅可为履行协议目的向确有必要知悉的人员或受托服务商披露,并应要求其承担不低于本协议的保密义务。 +3. 依法向有权机关披露的,接收方应在法律允许范围内及时通知披露方,并将披露限定在必要范围。 +4. 发生安全事件时,双方应及时协作调查、止损、保全证据并履行法定通知义务。 + +## 八、AI 服务特别约定 + +1. AI 输出属于辅助信息,不保证完整、准确、实时或适用于特定交易,不构成律师出具的正式法律意见。 +2. 客户应安排有相应权限和专业能力的人员复核最终合同、引用依据和业务决策。 +3. 未经客户单独授权,我们不会将客户合同、附件、提示或专有业务内容用于训练面向其他客户的通用模型。 +4. AI Provider 的选择可根据质量、安全和合规评估动态调整;发生重大影响时,我们将依法或依约告知。 + +## 九、客户义务与禁止行为 + +1. 客户应合法使用服务,管理账号权限,确保输入资料准确,并为其成员行为承担相应管理责任。 +2. 客户不得实施侵权、违法处理个人信息、上传恶意代码、攻击系统、规避用量或权限控制、未经授权抓取、反向工程或将服务用于违法自动化决策。 +3. 对可能造成重大安全或法律风险的行为,我们可采取必要的限制措施,并提供合理的通知和申诉渠道。 + +## 十、知识产权 + +1. 平台软件、通用模板、规则、模型配置、提示词、知识资产和技术成果归钰麒科技或合法权利人所有。 +2. 客户内容及客户自行形成的专有业务成果归客户或原权利人所有。双方另有书面约定的,从其约定。 +3. 平台输出可能包含通用或相似内容,不保证其唯一性。客户在公开、签署或商业使用前应完成必要的权利和法律审查。 + +## 十一、期限、暂停、终止与数据退出 + +1. 服务期限以订单或账号状态为准。任何一方可依据订单约定终止服务。 +2. 对严重违法、重大安全风险、持续欠费或实质违约,守约方可在合理催告后暂停或终止相关服务;紧急安全处置和依法必须立即采取措施的情况除外。 +3. 服务终止后,我们一般提供三十日数据导出窗口;法律、订单或双方书面约定另有规定的,从其规定。导出窗口届满后,数据按隐私政策删除或匿名化,依法留存的记录除外。 +4. 客户应在导出窗口内完成必要备份。账号终止不影响终止前已产生的付款、保密、知识产权、审计和争议解决义务。 + +## 十二、责任与不可抗力 + +1. 双方依法律规定、合同约定和各自过错承担责任,并应采取合理措施防止损失扩大。 +2. 本协议不排除或限制因故意、重大过失、人身损害、侵犯个人信息权益、违反保密义务或其他依法不得排除、限制的责任。 +3. 因不可抗力、公共网络故障、基础电信服务、监管措施或客户系统原因影响履行的,受影响方应及时通知并采取合理措施减少损失。 +4. 如订单约定责任上限、服务抵扣或专项赔偿机制,以不违反法律强制性规定为前提适用。 + +## 十三、通知、变更、法律适用与争议解决 + +1. 我们可通过站内通知、账号邮箱、公告或双方约定渠道发送服务通知。 +2. 对影响客户重大权益的协议变更,我们将提前以显著、可追溯方式通知;依法需要重新同意的,将重新取得同意。 +3. 本协议适用中华人民共和国法律(为本协议之目的,不包括冲突法规则)。争议协商不成的,任何一方可向内蒙古自治区包头市昆都仑区有管辖权的人民法院提起诉讼。 +4. 本协议版本为 2026-08-12,自 2026年8月13日 00:00(北京时间)起生效。既有用户应在生效后的下次登录时重新同意。 diff --git a/project-docs/legal-policy-candidates/2026-08-12/user-terms.md b/project-docs/legal-policy-candidates/2026-08-12/user-terms.md new file mode 100644 index 00000000..023232d0 --- /dev/null +++ b/project-docs/legal-policy-candidates/2026-08-12/user-terms.md @@ -0,0 +1,83 @@ +# 用户协议 + +版本:2026-08-12 + +生效时间:2026年8月13日 00:00(北京时间) + +运营者:内蒙古钰麒科技有限公司 + +## 一、协议主体与接受 + +1. 本协议由内蒙古钰麒科技有限公司(以下简称“钰麒科技”或“我们”)与使用钰麒合同平台及相关服务的用户(以下简称“用户”或“您”)订立。 +2. 您注册、登录、访问或使用平台前,应完整阅读并理解本协议、隐私政策、服务协议及 AI 使用与风险提示。您通过页面勾选、点击同意或继续使用依法构成对相应版本的接受。 +3. 代表企业或其他组织使用平台的人员,应保证已获得充分授权。组织管理员对工作空间成员、角色、权限和组织配置承担管理责任。 +4. 未满十八周岁的人员应在监护人同意和指导下使用;平台不面向未满十四周岁的儿童主动提供服务。 + +## 二、账号与工作空间 + +1. 您应提供真实、准确、完整且有效的注册资料,并及时更新。账号仅限本人或获授权人员使用,不得出租、出借、转让或共享认证凭据。 +2. 平台区分个人工作空间与组织工作空间。个人工作空间由个人用户管理;组织工作空间及其中业务数据由相应组织依其内部授权规则管理。 +3. 您应妥善保管密码、验证码、会话和其他认证凭据。发现异常登录、凭据泄露或未经授权操作时,应立即修改凭据并通过公开联系邮箱通知我们。 +4. 因您主动授权、组织管理员配置或您未妥善保管凭据导致的操作,在法律允许范围内由相应责任主体承担后果;平台存在过错的,依法承担相应责任。 + +## 三、服务内容与使用许可 + +1. 平台可提供合同模板起草、合同与附件管理、AI 审查、条款推荐、报告生成、审批协作、法律知识资产和审计记录等功能,具体以实际开放功能及订单约定为准。 +2. 在您遵守本协议的前提下,我们授予您一项有限的、非排他的、不可转让的服务使用许可。该许可不包含平台软件源代码、模型、规则、模板库或其他知识产权的转让。 +3. 试用、免费或测试功能可能受到期限、次数、存储空间、成员数量或其他限制。正式付费范围以订单、报价单或补充协议为准。 + +## 四、用户内容与授权 + +1. 您保留依法享有的合同正文、附件、业务资料和其他用户内容的权利,并保证对上传、处理和授权平台使用这些内容具有合法权利或充分授权。 +2. 为履行服务,我们仅在提供存储、解析、检索、AI 处理、报告生成、备份、安全防护和故障排查所必要的范围内处理用户内容。 +3. 未经您的单独授权,我们不会将您的合同正文、附件或专有业务内容用于训练面向其他客户的通用模型,也不会公开披露用户内容。 +4. 您不得上传国家法律法规禁止处理的内容,不得上传无权处理的个人信息、商业秘密、国家秘密、侵权材料或恶意代码。 + +## 五、AI 功能边界 + +1. AI 输出仅作为合同处理和风险识别的辅助信息,不构成律师出具的正式法律意见,不建立律师与客户关系,也不能替代具备相应资质人员的专业判断。 +2. AI 输出可能存在遗漏、错误、过时、虚构引用或不适用于特定地域、行业及事实背景的情况。您应核对事实、法律依据、引用来源、金额、期限、主体和最终文本。 +3. 诉讼仲裁、重大投融资、担保、劳动争议、监管许可、刑事风险、数据跨境及其他高风险事项,不得仅依据 AI 输出作出决定。 +4. AI 功能的详细规则以同版本《AI 使用与风险提示》为准。 + +## 六、禁止行为 + +您不得实施下列行为: + +1. 违反法律法规、监管要求、法院或行政机关有效决定; +2. 侵犯他人知识产权、个人信息权益、商业秘密或其他合法权益; +3. 绕过身份认证、权限隔离、用量限制、审计或安全控制; +4. 未经许可抓取、复制、反向工程、探测漏洞或干扰平台稳定运行; +5. 利用平台生成违法、欺诈、误导、歧视、骚扰或恶意内容; +6. 冒充他人、伪造授权、滥用组织管理员权限或访问不属于本工作空间的数据。 + +## 七、知识产权 + +1. 平台软件、界面、商标、产品设计、通用模板、规则、算法、提示词、知识资产和相关成果的知识产权归钰麒科技或合法权利人所有。 +2. 您对自行上传的内容及依法形成的业务成果保留相应权利。平台提供的通用模板或 AI 输出可能包含非独占、通用或相似表达,除法律另有规定或双方另有书面约定外,不保证其唯一性或可获得专有权利。 +3. 您提供的产品建议可用于改进服务,但我们不会据此取得您用户内容中的所有权或商业秘密权利。 + +## 八、服务变更、中止与终止 + +1. 我们可基于安全、合规、技术升级或运营需要调整服务,并对影响用户重大权益的变更以站内通知、邮件或其他可追溯方式提前通知,紧急安全处置除外。 +2. 对明显违法、严重违反协议、危害系统安全或侵害他人权益的行为,我们可采取限制功能、冻结账号或终止服务等必要措施,并在法律允许范围内说明原因和提供申诉渠道。 +3. 服务终止时,平台将依服务协议和隐私政策提供合理的数据导出、账号注销和删除安排;依法必须保留的记录除外。 + +## 九、责任边界 + +1. 我们将按照法律规定和约定采取合理措施保障服务安全与可用性,但互联网、第三方基础设施和 AI 模型具有不确定性,无法保证服务始终无中断或输出绝对准确。 +2. 因不可抗力、公共网络故障、基础电信服务、依法实施的监管措施或您自身系统原因造成的影响,双方应及时采取合理措施减少损失,并依法律规定承担责任。 +3. 本协议不排除或限制因故意、重大过失、人身损害、侵犯个人信息权益或其他依法不得排除、限制的责任。其他责任按法律规定、订单及双方过错承担。 + +## 十、通知、法律适用与争议解决 + +1. 我们可通过站内消息、账号绑定邮箱、公告或其他可追溯方式发送通知。涉及重大权益的通知将在合理范围内采用显著方式。 +2. 本协议适用中华人民共和国法律(为本协议之目的,不包括冲突法规则)。 +3. 因本协议产生或与本协议有关的争议,双方应先友好协商;协商不成的,任何一方可向内蒙古自治区包头市昆都仑区有管辖权的人民法院提起诉讼。 +4. 公开联系邮箱:admin@yuqei.com。 + +## 十一、版本与生效 + +1. 本协议版本为 2026-08-12,自 2026年8月13日 00:00(北京时间)起生效。 +2. 既有用户应在本版本生效后的下次登录时重新阅读并明确同意;未同意前,平台可依法限制需要该协议作为处理依据的功能。 +3. 历史版本、变更说明和生效时间应通过可追溯方式保留。重大变更将再次取得用户同意。 diff --git a/yuqei-infra/scripts/prepare-policy-legal-approval.py b/yuqei-infra/scripts/prepare-policy-legal-approval.py new file mode 100644 index 00000000..3e6ed1a1 --- /dev/null +++ b/yuqei-infra/scripts/prepare-policy-legal-approval.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +"""Prepare, finalize, sign, and verify named legal approval for public policies.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + + +PACKAGE_SCHEMA = "yuqei-public-policy-review-package-v1" +PENDING_PAYLOAD_SCHEMA = "yuqei-public-policy-approval-pending-v1" +APPROVED_PAYLOAD_SCHEMA = "yuqei-public-policy-approval-payload-v1" +APPROVAL_SCHEMA = "yuqei-public-policy-legal-approval-v1" +KEYRING_SCHEMA = "yuqei-public-policy-legal-keyring-v1" +SIGNATURE_DOMAIN = b"YUQEI_PUBLIC_POLICY_LEGAL_APPROVAL_V1\0" +SIGN_CONFIRMATION = "I_APPROVE_THIS_EXACT_POLICY_PAYLOAD" +EXPECTED_POLICY_IDS = { + "user_terms", + "privacy_policy", + "service_agreement", + "ai_use_notice", +} +MAX_POLICY_BYTES = 512 * 1024 +MAX_JSON_BYTES = 2 * 1024 * 1024 +MAX_APPROVAL_WINDOW = timedelta(days=7) +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +KEY_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$") +PROHIBITED_MARKERS = ("TODO", "TBD", "产品草案", "待补充", "待确认") + + +class PolicyApprovalError(ValueError): + pass + + +def canonical_json(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def parse_timestamp(value: str, *, label: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise PolicyApprovalError(f"{label} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise PolicyApprovalError(f"{label} must include a timezone") + return parsed + + +def normalized_timestamp(value: str, *, label: str) -> str: + return parse_timestamp(value, label=label).isoformat() + + +def read_bounded(path: Path, *, label: str, max_bytes: int) -> bytes: + try: + size = path.stat().st_size + except OSError as exc: + raise PolicyApprovalError(f"{label} is unavailable: {path}") from exc + if size <= 0 or size > max_bytes: + raise PolicyApprovalError(f"{label} size is outside the allowed range") + return path.read_bytes() + + +def strict_json(raw: bytes, *, label: str) -> dict[str, Any]: + def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise PolicyApprovalError(f"{label} contains duplicate key: {key}") + result[key] = value + return result + + try: + value = json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + except (UnicodeError, json.JSONDecodeError) as exc: + raise PolicyApprovalError(f"{label} is not valid UTF-8 JSON") from exc + if not isinstance(value, dict): + raise PolicyApprovalError(f"{label} must be a JSON object") + return value + + +def write_atomic(path: Path, raw: bytes, *, private: bool = False) -> None: + path = path.resolve() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + temporary.write_bytes(raw) + if private and os.name != "nt": + temporary.chmod(0o600) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def load_public_key(path: Path) -> tuple[Ed25519PublicKey, bytes, bytes]: + pem = read_bounded(path, label="policy legal public key", max_bytes=64 * 1024) + try: + key = serialization.load_pem_public_key(pem) + except ValueError as exc: + raise PolicyApprovalError("policy legal public key is not valid PEM") from exc + if not isinstance(key, Ed25519PublicKey): + raise PolicyApprovalError("policy legal public key must be Ed25519") + raw = key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return key, pem, raw + + +def load_policy_material(policy_dir: Path) -> tuple[dict[str, Any], bytes, list[dict[str, Any]]]: + policy_dir = policy_dir.resolve() + manifest_path = policy_dir / "manifest.json" + manifest_raw = read_bounded(manifest_path, label="policy manifest", max_bytes=MAX_JSON_BYTES) + manifest = strict_json(manifest_raw, label="policy manifest") + if manifest.get("schema_version") != "yuqei-public-policy-candidate-v1": + raise PolicyApprovalError("policy manifest schema_version is invalid") + + version = _required_text(manifest, "version") + effective_at = normalized_timestamp(_required_text(manifest, "effective_at"), label="effective_at") + if effective_at != manifest["effective_at"]: + raise PolicyApprovalError("effective_at must use canonical ISO-8601 formatting") + publisher = manifest.get("publisher") + if not isinstance(publisher, dict): + raise PolicyApprovalError("policy publisher must be an object") + for field in ( + "legal_name", + "unified_social_credit_code", + "registered_address", + "public_contact_email", + "privacy_contact_email", + ): + _required_text(publisher, field) + if manifest.get("existing_user_reconsent") != "required_on_next_login": + raise PolicyApprovalError("existing users must re-consent on next login") + if manifest.get("approval_window_days") != 7: + raise PolicyApprovalError("policy approval window must be exactly seven days") + + documents = manifest.get("documents") + if not isinstance(documents, list) or len(documents) != len(EXPECTED_POLICY_IDS): + raise PolicyApprovalError("policy manifest must bind exactly four documents") + material: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + seen_files: set[str] = set() + for document in documents: + if not isinstance(document, dict): + raise PolicyApprovalError("policy document manifest entry must be an object") + policy_id = _required_text(document, "policy_id") + title = _required_text(document, "title") + public_path = _required_text(document, "public_path") + source_file = _required_text(document, "source_file") + if Path(source_file).name != source_file or source_file in seen_files: + raise PolicyApprovalError("policy source_file must be unique and traversal-free") + if policy_id in seen_ids: + raise PolicyApprovalError("policy_id values must be unique") + source_path = policy_dir / source_file + raw = read_bounded(source_path, label=f"policy document {policy_id}", max_bytes=MAX_POLICY_BYTES) + if b"\r" in raw or raw.startswith(b"\xef\xbb\xbf"): + raise PolicyApprovalError(f"policy document {policy_id} must use BOM-free LF UTF-8") + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise PolicyApprovalError(f"policy document {policy_id} is not UTF-8") from exc + if not text.startswith(f"# {title}\n"): + raise PolicyApprovalError(f"policy document {policy_id} title does not match manifest") + if version not in text or "2026年8月13日 00:00(北京时间)" not in text: + raise PolicyApprovalError(f"policy document {policy_id} omits version or effective time") + markers = [marker for marker in PROHIBITED_MARKERS if marker in text] + if markers: + raise PolicyApprovalError(f"policy document {policy_id} contains unresolved markers") + material.append( + { + "policy_id": policy_id, + "title": title, + "public_path": public_path, + "source_file": source_file, + "sha256": sha256_bytes(raw), + "size_bytes": len(raw), + } + ) + seen_ids.add(policy_id) + seen_files.add(source_file) + if seen_ids != EXPECTED_POLICY_IDS: + raise PolicyApprovalError("policy manifest contains an unexpected policy set") + material.sort(key=lambda item: item["policy_id"]) + return manifest, manifest_raw, material + + +def build_review_package( + *, + policy_dir: Path, + public_key_file: Path, + key_id: str, + approver_id: str, + reviewer: str, + role: str, + organization: str, + prepared_at: str, + key_not_before: str, + key_not_after: str, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + if not KEY_ID_PATTERN.fullmatch(key_id): + raise PolicyApprovalError("key_id format is invalid") + prepared_at = normalized_timestamp(prepared_at, label="prepared_at") + key_not_before = normalized_timestamp(key_not_before, label="key_not_before") + key_not_after = normalized_timestamp(key_not_after, label="key_not_after") + if parse_timestamp(key_not_after, label="key_not_after") <= parse_timestamp( + key_not_before, + label="key_not_before", + ): + raise PolicyApprovalError("key_not_after must be later than key_not_before") + manifest, manifest_raw, documents = load_policy_material(policy_dir) + _, public_pem, public_raw = load_public_key(public_key_file) + identity = { + "approver_id": _nonempty(approver_id, "approver_id"), + "reviewer": _nonempty(reviewer, "reviewer"), + "role": _nonempty(role, "role"), + "organization": _nonempty(organization, "organization"), + } + if identity["organization"] != manifest["publisher"]["legal_name"]: + raise PolicyApprovalError("approver organization must match the policy publisher") + + review_package = { + "schema_version": PACKAGE_SCHEMA, + "status": "pending_named_legal_review", + "prepared_at": prepared_at, + "policy_dir_name": policy_dir.resolve().name, + "policy_version": manifest["version"], + "effective_at": manifest["effective_at"], + "manifest_sha256": sha256_bytes(manifest_raw), + "documents": documents, + "publisher": manifest["publisher"], + "governing_law": manifest["governing_law"], + "dispute_resolution": manifest["dispute_resolution"], + "existing_user_reconsent": manifest["existing_user_reconsent"], + "approval_window_days": manifest["approval_window_days"], + "approver": identity, + "key": { + "key_id": key_id, + "algorithm": "Ed25519", + "public_key_file_sha256": sha256_bytes(public_pem), + "public_key_raw_sha256": sha256_bytes(public_raw), + "not_before": key_not_before, + "not_after": key_not_after, + }, + } + review_package_raw = canonical_json(review_package) + pending_payload = { + "schema_version": PENDING_PAYLOAD_SCHEMA, + "status": "pending_named_legal_review", + "prepared_at": prepared_at, + "review_package_sha256": sha256_bytes(review_package_raw), + "policy_version": manifest["version"], + "effective_at": manifest["effective_at"], + "documents": documents, + "publisher": manifest["publisher"], + "governing_law": manifest["governing_law"], + "dispute_resolution": manifest["dispute_resolution"], + "existing_user_reconsent": manifest["existing_user_reconsent"], + "approval_request": { + **identity, + "key_id": key_id, + "algorithm": "Ed25519", + "public_key_file_sha256": sha256_bytes(public_pem), + "public_key_raw_sha256": sha256_bytes(public_raw), + "key_not_before": key_not_before, + "key_not_after": key_not_after, + "maximum_approval_window_seconds": int(MAX_APPROVAL_WINDOW.total_seconds()), + "required_confirmation": SIGN_CONFIRMATION, + }, + } + keyring_candidate = { + "schema_version": KEYRING_SCHEMA, + "status": "candidate_not_trusted", + "keys": [ + { + **identity, + "key_id": key_id, + "algorithm": "Ed25519", + "public_key_pem": public_pem.decode("ascii"), + "public_key_file_sha256": sha256_bytes(public_pem), + "public_key_raw_sha256": sha256_bytes(public_raw), + "not_before": key_not_before, + "not_after": key_not_after, + "revoked": False, + } + ], + } + return review_package, pending_payload, keyring_candidate + + +def finalize_payload( + pending_payload: dict[str, Any], + *, + approved_at: str, + expires_at: str, +) -> dict[str, Any]: + if pending_payload.get("schema_version") != PENDING_PAYLOAD_SCHEMA: + raise PolicyApprovalError("pending payload schema_version is invalid") + if pending_payload.get("status") != "pending_named_legal_review": + raise PolicyApprovalError("only a pending policy payload can be finalized") + approved = parse_timestamp(approved_at, label="approved_at") + expires = parse_timestamp(expires_at, label="expires_at") + prepared = parse_timestamp(_required_text(pending_payload, "prepared_at"), label="prepared_at") + if approved < prepared: + raise PolicyApprovalError("approved_at cannot precede prepared_at") + if expires <= approved or expires - approved > MAX_APPROVAL_WINDOW: + raise PolicyApprovalError("approval validity must be greater than zero and no longer than seven days") + request = pending_payload.get("approval_request") + if not isinstance(request, dict): + raise PolicyApprovalError("pending approval_request is invalid") + key_not_before = parse_timestamp(_required_text(request, "key_not_before"), label="key_not_before") + key_not_after = parse_timestamp(_required_text(request, "key_not_after"), label="key_not_after") + if approved < key_not_before or expires > key_not_after: + raise PolicyApprovalError("approval window must remain within the signing key validity window") + return { + **pending_payload, + "schema_version": APPROVED_PAYLOAD_SCHEMA, + "status": "approved", + "pending_payload_sha256": sha256_bytes(canonical_json(pending_payload)), + "decision": { + "decision": "approved", + "approved_at": approved.isoformat(), + "expires_at": expires.isoformat(), + "confirmation": SIGN_CONFIRMATION, + }, + } + + +def sign_payload( + payload: dict[str, Any], + *, + private_key_file: Path, + password_file: Path, + confirmation: str, +) -> dict[str, Any]: + if confirmation != SIGN_CONFIRMATION: + raise PolicyApprovalError(f"signing requires confirmation {SIGN_CONFIRMATION}") + if payload.get("schema_version") != APPROVED_PAYLOAD_SCHEMA or payload.get("status") != "approved": + raise PolicyApprovalError("only a finalized approved payload can be signed") + private_raw = read_bounded(private_key_file, label="policy legal private key", max_bytes=64 * 1024) + password = read_bounded(password_file, label="policy legal private key password", max_bytes=4096).strip() + if not password: + raise PolicyApprovalError("policy legal private key password is empty") + try: + key = serialization.load_pem_private_key(private_raw, password=password) + except (TypeError, ValueError) as exc: + raise PolicyApprovalError("policy legal private key or password is invalid") from exc + if not isinstance(key, Ed25519PrivateKey): + raise PolicyApprovalError("policy legal private key must be Ed25519") + public_raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + expected = payload.get("approval_request", {}).get("public_key_raw_sha256") + if sha256_bytes(public_raw) != expected: + raise PolicyApprovalError("private key does not match the approved public key binding") + payload_raw = canonical_json(payload) + signature = key.sign(SIGNATURE_DOMAIN + payload_raw) + approval_id = f"policy-approval-{sha256_bytes(payload_raw + signature)[:24]}" + return { + "schema_version": APPROVAL_SCHEMA, + "approval_id": approval_id, + "payload": payload, + "signature": base64.b64encode(signature).decode("ascii"), + } + + +def verify_approval( + approval: dict[str, Any], + *, + keyring: dict[str, Any], + policy_dir: Path, + checked_at: str, + allow_candidate_keyring: bool, +) -> dict[str, Any]: + if approval.get("schema_version") != APPROVAL_SCHEMA: + raise PolicyApprovalError("policy approval schema_version is invalid") + payload = approval.get("payload") + if not isinstance(payload, dict) or payload.get("schema_version") != APPROVED_PAYLOAD_SCHEMA: + raise PolicyApprovalError("signed policy approval payload is invalid") + decision = payload.get("decision") + if not isinstance(decision, dict) or decision.get("decision") != "approved": + raise PolicyApprovalError("policy approval decision is not approved") + checked = parse_timestamp(checked_at, label="checked_at") + approved = parse_timestamp(_required_text(decision, "approved_at"), label="approved_at") + expires = parse_timestamp(_required_text(decision, "expires_at"), label="expires_at") + if checked < approved or checked > expires: + raise PolicyApprovalError("policy approval is not valid at checked_at") + + manifest, _, documents = load_policy_material(policy_dir) + if documents != payload.get("documents"): + raise PolicyApprovalError("policy document bytes do not match the signed payload") + if manifest.get("version") != payload.get("policy_version") or manifest.get("effective_at") != payload.get("effective_at"): + raise PolicyApprovalError("policy manifest version binding does not match the signed payload") + + if keyring.get("schema_version") != KEYRING_SCHEMA: + raise PolicyApprovalError("policy keyring schema_version is invalid") + if keyring.get("status") != "trusted" and not ( + allow_candidate_keyring and keyring.get("status") == "candidate_not_trusted" + ): + raise PolicyApprovalError("policy keyring is not trusted") + keys = keyring.get("keys") + if not isinstance(keys, list): + raise PolicyApprovalError("policy keyring keys are invalid") + request = payload.get("approval_request") + if not isinstance(request, dict): + raise PolicyApprovalError("policy approval_request is invalid") + matches = [item for item in keys if isinstance(item, dict) and item.get("key_id") == request.get("key_id")] + if len(matches) != 1: + raise PolicyApprovalError("policy signing key must resolve uniquely") + entry = matches[0] + if entry.get("revoked") is not False: + raise PolicyApprovalError("policy signing key is revoked") + key_not_before = parse_timestamp(_required_text(entry, "not_before"), label="key not_before") + key_not_after = parse_timestamp(_required_text(entry, "not_after"), label="key not_after") + if approved < key_not_before or expires > key_not_after: + raise PolicyApprovalError("policy approval is outside the signing key validity window") + for field in ("approver_id", "reviewer", "role", "organization", "public_key_raw_sha256"): + if entry.get(field) != request.get(field): + raise PolicyApprovalError(f"policy key identity mismatch: {field}") + public_pem = entry.get("public_key_pem") + if not isinstance(public_pem, str) or not public_pem.strip(): + raise PolicyApprovalError("policy keyring public key is invalid") + try: + public_key = serialization.load_pem_public_key(public_pem.encode("ascii")) + except (UnicodeError, ValueError) as exc: + raise PolicyApprovalError("policy keyring public key is invalid") from exc + if not isinstance(public_key, Ed25519PublicKey): + raise PolicyApprovalError("policy keyring public key must be Ed25519") + try: + signature = base64.b64decode(_required_text(approval, "signature"), validate=True) + public_key.verify(signature, SIGNATURE_DOMAIN + canonical_json(payload)) + except Exception as exc: + raise PolicyApprovalError("policy approval signature verification failed") from exc + expected_id = f"policy-approval-{sha256_bytes(canonical_json(payload) + signature)[:24]}" + if approval.get("approval_id") != expected_id: + raise PolicyApprovalError("policy approval_id does not match its signed material") + return { + "status": "verified", + "approval_id": expected_id, + "policy_version": payload["policy_version"], + "effective_at": payload["effective_at"], + "checked_at": checked.isoformat(), + "documents": documents, + } + + +def _required_text(value: dict[str, Any], key: str) -> str: + return _nonempty(value.get(key), key) + + +def _nonempty(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise PolicyApprovalError(f"{label} must be a trimmed non-empty string") + return value + + +def _load_json_file(path: Path, *, label: str) -> dict[str, Any]: + return strict_json(read_bounded(path, label=label, max_bytes=MAX_JSON_BYTES), label=label) + + +def _prepare_command(args: argparse.Namespace) -> dict[str, Any]: + package, payload, keyring = build_review_package( + policy_dir=args.policy_dir, + public_key_file=args.public_key_file, + key_id=args.key_id, + approver_id=args.approver_id, + reviewer=args.reviewer, + role=args.role, + organization=args.organization, + prepared_at=args.prepared_at or datetime.now(timezone.utc).astimezone().isoformat(), + key_not_before=args.key_not_before, + key_not_after=args.key_not_after, + ) + package_raw = canonical_json(package) + payload_raw = canonical_json(payload) + keyring_raw = canonical_json(keyring) + write_atomic(args.review_package_output, package_raw) + write_atomic(args.payload_output, payload_raw) + write_atomic(args.keyring_output, keyring_raw) + return { + "status": "prepared_not_signed", + "review_package_sha256": sha256_bytes(package_raw), + "payload_sha256": sha256_bytes(payload_raw), + "keyring_sha256": sha256_bytes(keyring_raw), + "documents": package["documents"], + } + + +def _finalize_command(args: argparse.Namespace) -> dict[str, Any]: + pending = _load_json_file(args.pending_payload_file, label="pending policy payload") + payload = finalize_payload(pending, approved_at=args.approved_at, expires_at=args.expires_at) + raw = canonical_json(payload) + write_atomic(args.output, raw) + return {"status": "finalized_not_signed", "payload_sha256": sha256_bytes(raw)} + + +def _sign_command(args: argparse.Namespace) -> dict[str, Any]: + payload = _load_json_file(args.payload_file, label="final policy payload") + approval = sign_payload( + payload, + private_key_file=args.private_key_file, + password_file=args.private_key_password_file, + confirmation=args.confirmation, + ) + raw = canonical_json(approval) + write_atomic(args.output, raw, private=True) + return { + "status": "signed", + "approval_id": approval["approval_id"], + "approval_sha256": sha256_bytes(raw), + } + + +def _verify_command(args: argparse.Namespace) -> dict[str, Any]: + approval = _load_json_file(args.approval_file, label="policy approval") + keyring = _load_json_file(args.keyring_file, label="policy keyring") + return verify_approval( + approval, + keyring=keyring, + policy_dir=args.policy_dir, + checked_at=args.checked_at or datetime.now(timezone.utc).astimezone().isoformat(), + allow_candidate_keyring=args.allow_candidate_keyring, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + prepare = commands.add_parser("prepare", help="create unsigned policy review material") + prepare.add_argument("--policy-dir", type=Path, required=True) + prepare.add_argument("--public-key-file", type=Path, required=True) + prepare.add_argument("--key-id", required=True) + prepare.add_argument("--approver-id", required=True) + prepare.add_argument("--reviewer", required=True) + prepare.add_argument("--role", required=True) + prepare.add_argument("--organization", required=True) + prepare.add_argument("--prepared-at") + prepare.add_argument("--key-not-before", required=True) + prepare.add_argument("--key-not-after", required=True) + prepare.add_argument("--review-package-output", type=Path, required=True) + prepare.add_argument("--payload-output", type=Path, required=True) + prepare.add_argument("--keyring-output", type=Path, required=True) + + finalize = commands.add_parser("finalize-payload", help="bind approval timestamps after review") + finalize.add_argument("--pending-payload-file", type=Path, required=True) + finalize.add_argument("--approved-at", required=True) + finalize.add_argument("--expires-at", required=True) + finalize.add_argument("--output", type=Path, required=True) + + sign = commands.add_parser("sign", help="sign a finalized payload with the reviewer-owned key") + sign.add_argument("--payload-file", type=Path, required=True) + sign.add_argument("--private-key-file", type=Path, required=True) + sign.add_argument("--private-key-password-file", type=Path, required=True) + sign.add_argument("--confirmation", required=True) + sign.add_argument("--output", type=Path, required=True) + + verify = commands.add_parser("verify", help="verify signature, key identity, and policy bytes") + verify.add_argument("--approval-file", type=Path, required=True) + verify.add_argument("--keyring-file", type=Path, required=True) + verify.add_argument("--policy-dir", type=Path, required=True) + verify.add_argument("--checked-at") + verify.add_argument("--allow-candidate-keyring", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.command == "prepare": + result = _prepare_command(args) + elif args.command == "finalize-payload": + result = _finalize_command(args) + elif args.command == "sign": + result = _sign_command(args) + else: + result = _verify_command(args) + except (OSError, PolicyApprovalError) as exc: + print(f"POLICY_LEGAL_APPROVAL_FAIL error={exc}", file=sys.stderr) + return 2 + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/tests/test_policy_legal_signing_helper.py b/yuqei-infra/tests/test_policy_legal_signing_helper.py new file mode 100644 index 00000000..84c2cb58 --- /dev/null +++ b/yuqei-infra/tests/test_policy_legal_signing_helper.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import importlib.util +import json +import shutil +import sys +from pathlib import Path +from types import ModuleType + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +REPO_ROOT = Path(__file__).resolve().parents[2] +POLICY_SOURCE = REPO_ROOT / "project-docs" / "legal-policy-candidates" / "2026-08-12" +PASSWORD = b"policy-test-password" + + +def load_script() -> ModuleType: + path = REPO_ROOT / "yuqei-infra" / "scripts" / "prepare-policy-legal-approval.py" + spec = importlib.util.spec_from_file_location("yuqei_policy_legal_approval", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[module.__name__] = module + spec.loader.exec_module(module) + return module + + +policy = load_script() + + +def make_key_files(tmp_path: Path, *, stem: str = "policy") -> tuple[Path, Path, Path]: + key = Ed25519PrivateKey.generate() + private_path = tmp_path / f"{stem}-private.pem" + public_path = tmp_path / f"{stem}-public.pem" + password_path = tmp_path / f"{stem}-password.txt" + private_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(PASSWORD), + ) + ) + public_path.write_bytes( + key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + password_path.write_bytes(PASSWORD) + return private_path, public_path, password_path + + +def prepare(tmp_path: Path, *, policy_dir: Path = POLICY_SOURCE) -> tuple[dict, dict, dict, Path, Path, Path]: + private_path, public_path, password_path = make_key_files(tmp_path) + package, pending, keyring = policy.build_review_package( + policy_dir=policy_dir, + public_key_file=public_path, + key_id="policy-legal-2026q3", + approver_id="cyuqei@gmail.com", + reviewer="陈钰麒", + role="CEO", + organization="内蒙古钰麒科技有限公司", + prepared_at="2026-08-12T12:00:00+08:00", + key_not_before="2026-07-01T00:00:00+08:00", + key_not_after="2026-09-30T23:59:59+08:00", + ) + return package, pending, keyring, private_path, public_path, password_path + + +def test_prepare_binds_exact_four_policy_files_and_public_key(tmp_path: Path) -> None: + package, pending, keyring, _private, public, _password = prepare(tmp_path) + + assert package["status"] == "pending_named_legal_review" + assert pending["status"] == "pending_named_legal_review" + assert {item["policy_id"] for item in package["documents"]} == policy.EXPECTED_POLICY_IDS + assert all(len(item["sha256"]) == 64 and item["size_bytes"] > 0 for item in package["documents"]) + assert pending["review_package_sha256"] == policy.sha256_bytes(policy.canonical_json(package)) + assert keyring["status"] == "candidate_not_trusted" + assert keyring["keys"][0]["public_key_file_sha256"] == policy.sha256_bytes(public.read_bytes()) + assert keyring["keys"][0]["not_after"] == "2026-09-30T23:59:59+08:00" + + +def test_prepare_rejects_unresolved_or_modified_candidate_marker(tmp_path: Path) -> None: + policy_dir = tmp_path / "policies" + shutil.copytree(POLICY_SOURCE, policy_dir) + terms = policy_dir / "user-terms.md" + terms.write_text(terms.read_text(encoding="utf-8") + "\nTODO\n", encoding="utf-8", newline="\n") + _private, public, _password = make_key_files(tmp_path) + + with pytest.raises(policy.PolicyApprovalError, match="unresolved markers"): + policy.build_review_package( + policy_dir=policy_dir, + public_key_file=public, + key_id="policy-legal-2026q3", + approver_id="cyuqei@gmail.com", + reviewer="陈钰麒", + role="CEO", + organization="内蒙古钰麒科技有限公司", + prepared_at="2026-08-12T12:00:00+08:00", + key_not_before="2026-07-01T00:00:00+08:00", + key_not_after="2026-09-30T23:59:59+08:00", + ) + + +def test_finalize_rejects_approval_longer_than_seven_days(tmp_path: Path) -> None: + _package, pending, _keyring, *_ = prepare(tmp_path) + + with pytest.raises(policy.PolicyApprovalError, match="no longer than seven days"): + policy.finalize_payload( + pending, + approved_at="2026-08-12T13:00:00+08:00", + expires_at="2026-08-19T13:00:01+08:00", + ) + + +def test_sign_and_verify_exact_policy_bytes_with_candidate_override(tmp_path: Path) -> None: + _package, pending, keyring, private, _public, password = prepare(tmp_path) + final_payload = policy.finalize_payload( + pending, + approved_at="2026-08-12T13:00:00+08:00", + expires_at="2026-08-19T13:00:00+08:00", + ) + approval = policy.sign_payload( + final_payload, + private_key_file=private, + password_file=password, + confirmation=policy.SIGN_CONFIRMATION, + ) + + with pytest.raises(policy.PolicyApprovalError, match="keyring is not trusted"): + policy.verify_approval( + approval, + keyring=keyring, + policy_dir=POLICY_SOURCE, + checked_at="2026-08-12T14:00:00+08:00", + allow_candidate_keyring=False, + ) + + verified = policy.verify_approval( + approval, + keyring=keyring, + policy_dir=POLICY_SOURCE, + checked_at="2026-08-12T14:00:00+08:00", + allow_candidate_keyring=True, + ) + assert verified["status"] == "verified" + assert verified["approval_id"] == approval["approval_id"] + + +def test_verify_rejects_policy_byte_tampering(tmp_path: Path) -> None: + policy_dir = tmp_path / "policies" + shutil.copytree(POLICY_SOURCE, policy_dir) + _package, pending, keyring, private, _public, password = prepare(tmp_path, policy_dir=policy_dir) + final_payload = policy.finalize_payload( + pending, + approved_at="2026-08-12T13:00:00+08:00", + expires_at="2026-08-19T13:00:00+08:00", + ) + approval = policy.sign_payload( + final_payload, + private_key_file=private, + password_file=password, + confirmation=policy.SIGN_CONFIRMATION, + ) + terms = policy_dir / "user-terms.md" + terms.write_text(terms.read_text(encoding="utf-8") + "\n", encoding="utf-8", newline="\n") + + with pytest.raises(policy.PolicyApprovalError, match="document bytes"): + policy.verify_approval( + approval, + keyring=keyring, + policy_dir=policy_dir, + checked_at="2026-08-12T14:00:00+08:00", + allow_candidate_keyring=True, + ) + + +def test_sign_rejects_a_different_private_key(tmp_path: Path) -> None: + _package, pending, _keyring, _private, _public, _password = prepare(tmp_path) + other_private, _other_public, other_password = make_key_files(tmp_path, stem="other") + final_payload = policy.finalize_payload( + pending, + approved_at="2026-08-12T13:00:00+08:00", + expires_at="2026-08-19T13:00:00+08:00", + ) + + with pytest.raises(policy.PolicyApprovalError, match="does not match"): + policy.sign_payload( + final_payload, + private_key_file=other_private, + password_file=other_password, + confirmation=policy.SIGN_CONFIRMATION, + )