From bec8b4c72465047f891e5ad04a72050136263dad Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 21 Jul 2026 20:40:31 +0800 Subject: [PATCH 1/2] feat: add secure support bundle triage --- README.md | 2 + docs/index.html | 3 +- docs/progress.html | 11 +- docs/support.html | 41 ++ scripts/inspect_support_bundle.py | 571 +++++++++++++++++++ scripts/tests/test_documentation.py | 76 +++ scripts/tests/test_inspect_support_bundle.py | 218 +++++++ 7 files changed, 916 insertions(+), 6 deletions(-) create mode 100644 docs/support.html create mode 100644 scripts/inspect_support_bundle.py create mode 100644 scripts/tests/test_documentation.py create mode 100644 scripts/tests/test_inspect_support_bundle.py diff --git a/README.md b/README.md index 89177d9..707def7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 开发执行参考 [`docs/development-plan.html`](./docs/development-plan.html),日常状态维护在 [`docs/progress.html`](./docs/progress.html)。 +支持包接收与分流见 [`docs/support.html`](./docs/support.html)。接收端必须先执行 `python scripts/inspect_support_bundle.py validate `,再使用 `summarize` 生成不含请求内容的工单摘要。 + ## 当前实现 当前实现包含版本化 Prompt IR、隔离式 OpenAI-compatible 适配器注册表、共享捕获策略、可信 Core 入口、SQLCipher 加密存储、可双向流式转发的本地 AI Gateway,以及 React + Tauri 桌面工作台。 diff --git a/docs/index.html b/docs/index.html index bfdac9d..10f513e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -11,7 +11,7 @@ @@ -56,6 +56,7 @@

文档结构

MVP 交付范围、阶段、验收 开发计划工作分解、迭代与依赖 签名发布发布工作流、制品门禁与回滚演练 + 支持处理支持包验证、诊断分流与证据保留 进度跟踪里程碑、任务与风险台账 diff --git a/docs/progress.html b/docs/progress.html index 72842d0..81d7ddc 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -16,13 +16,13 @@

开发进度跟踪

本页是项目状态的单一入口。开发计划定义“应该做什么”,本页只记录“当前做到哪里、是否健康、下一步是什么”。

-
工程进度81%
+
工程进度82%
当前阶段恢复、安全与稳定性
当前迭代S5 / S7 / S8 · 进行中
最后更新2026-07-21
-
当前结论四厂商 Prompt IR、搜索、Compare 与安全导出已闭环;三平台桌面 IPC 已启用 OS 级用户隔离与逐连接 sidecar 身份核对。签名发布流水线会校验平台签名、notarization、updater 签名、版本与制品哈希;下一步配置正式凭据并执行真实发布/回滚演练、管理员故障矩阵、屏幕阅读器验收与插件 WASI 最小权限。
+
当前结论四厂商 Prompt IR、搜索、Compare、安全导出与支持处理已闭环;三平台桌面 IPC 已启用 OS 级用户隔离与逐连接 sidecar 身份核对。签名发布流水线会校验平台签名、notarization、updater 签名、版本与制品哈希;下一步配置正式凭据并执行真实发布/回滚演练、管理员故障矩阵、屏幕阅读器验收与插件 WASI 最小权限。

1. 更新规则

    @@ -62,7 +62,7 @@

    4. 工作流进度

    Capture / NetworkCAP-001~00788%In progressCodex / TBD完成 macOS helper 真实故障矩阵;生产签名随发布凭据补齐 Prompt / AdaptersPAR-001~007100%DoneCodex / TBD保持价格目录与 provider usage 映射可追溯 Data / SecurityDAT-001~002、SEC-001~00495%In progressCodex / TBD继续 WASI 权限、正式签名制品与独立安全评审 - Test / ReleaseTST-001~004、REL-001~00372%In progressCodex / TBD继续更新回滚演练、屏幕阅读器与支持处理流程 + Test / ReleaseTST-001~004、REL-001~00378%In progressCodex / TBD继续更新回滚演练、屏幕阅读器与 GA 发布演练

    5. 当前迭代:S5 / S6

    @@ -103,7 +103,7 @@

    5. 当前迭代:S5 / S6

    TST-003平台故障注入In progress80%Codex / TBD2026-08-25Windows/macOS 强杀恢复、活 owner 防误恢复、sidecar 独立退出回退和端口冲突拒绝已通过;PAC 原状态下应用与同步回滚双重失败会交由独立 watchdog 恢复;macOS CA 拒绝恢复及 helper 协议、权限、命令转义和恢复文件校验已覆盖;授权桌面 helper 的真实故障矩阵待完成 TST-004UI、性能与可访问性In progress80%Codex / TBD2026-09-08axe、WAI-ARIA tabs、方向键、焦点循环/恢复、键盘分隔条和双主题对比度已门禁;Ubuntu Chromium 固定 960×620 明色与 1440×900 暗色像素基线,千条列表要求首屏 <2 秒、首尾滚动 <2 秒、筛选 <750 ms 且可见 DOM <40 行;Gateway fake provider 基准要求额外延迟 P95 <20 ms,最新本地基线为 0.609 ms;屏幕阅读器人工验收待完成 REL-001签名安装、升级和回滚In progress80%Codex / Release Owner TBD2026-09-22应用内签名更新、预更新恢复快照与失败后只读历史已实现;发布流水线会生成并验证 Windows/macOS 安装包、updater archive、`.sig`、`latest.json` 与哈希清单,先上传草稿再公开。正式证书/密钥托管、首批签名制品与隔离设备真实回滚演练待完成 - REL-002诊断与支持包In progress75%Codex / Support Owner TBD2026-09-22版本化 JSON 支持包可预览、复制和保存,包含不带请求标识的兼容诊断树;256 KiB code-only journal 与最近 100 条事件接入,排除 Prompt、Raw 和日志详情;支持处理流程待完成 + REL-002诊断与支持包Done100%Codex / Support Owner TBD2026-07-21版本化 JSON 支持包可预览、复制和保存;接收端严格拒绝超限、重复键、未知格式、异常隐私声明、请求内容字段与凭据模式,只生成无内容诊断摘要;分流、升级、保留和删除流程已发布 SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过 SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过 SPIKE-003Windows/macOS 代理恢复验证Done100%Codex / TBD2026-07-14Windows / macOS 真实强杀恢复通过 @@ -129,7 +129,7 @@

    6. 后续迭代承诺

    S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressSEC-002、CAP-007 完成,IPC 抗阻塞、双模式 OS PID 归因、Unix owner-only socket、Windows owner-only named pipe、readiness 身份校验、PAC/CA 拒绝恢复、macOS 授权 helper 和兼容诊断树已接入;继续真实故障验收与 WASI 最小权限 S6PAR-005~007、APP-005Done四厂商适配器、token/成本/指纹、全文搜索和结构/文本 Compare 全部完成 S7TST-004、性能、可访问性、诊断与保留In progressaxe、键盘与焦点、双主题截图、千条列表预算及 Gateway P95 门禁完成;继续屏幕阅读器和 Beta 真实环境测量 - S8SEC-004、REL-001/002、安全评审In progress仓库供应链门禁、失败关闭的跨平台签名发布流水线、应用内更新、预更新恢复快照、只读历史恢复与 code-only 支持包已接入;正式凭据、首批制品、真实回滚、支持流程与安全评审待推进 + S8SEC-004、REL-001/002、安全评审In progress仓库供应链门禁、失败关闭的跨平台签名发布流水线、应用内更新、预更新恢复快照、只读历史恢复与支持处理闭环已接入;正式凭据、首批制品、真实回滚与安全评审待推进 S9REL-003、完整回归与发布演练Not started阻断项清零 @@ -236,6 +236,7 @@

    11. 决策与变更记录

    2026-07-21Security / Supply chainARC-001 完成,SEC-004 仓库门禁落地CODEOWNERS 覆盖安全边界;CI Action 固定完整 SHA,并通过依赖审查拒绝新增高危漏洞;Dependabot 覆盖四类依赖生态;确定性脚本与单测校验 npm、pip、Cargo、锁文件和仓库策略Codex 2026-07-21Release / UpdaterREL-001 / SEC-004 签名更新入口完成首段Tauri updater 使用编译时可信公钥和固定 HTTPS GitHub Release endpoint;安装前重新检查版本、恢复系统代理、切回 Gateway,并建立可验证恢复的 SQLCipher 备份和版本化 journal;设置页显示可用版本、说明和下载进度,未配置公钥时失败关闭Codex 2026-07-21Security / DependenciesSEC-004 兼容漏洞修复npm diff 升至 8.0.3,两份 Cargo lock 的 time 升至 0.3.47,桌面 serde_with 升至 3.21.0,并将声明 MSRV 提升到依赖要求的 Rust 1.88;glib 告警来自最新版 Tauri 的 Linux GTK3 链,仓库及 Tauri/Wry 未调用受影响 VariantStrIter,单独按 not-used 证据处置Codex + 2026-07-21Release / SupportREL-002 支持处理闭环完成接收端工具严格验证支持包格式、隐私不变量、文件属性、大小、重复键、内容字段和共享凭据 corpus,只输出不含 endpoint、指纹、运行时详情与请求内容的摘要;支持分流、升级、14 天保留和案件关闭删除流程已发布Codex 后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。 diff --git a/docs/support.html b/docs/support.html new file mode 100644 index 0000000..3d68190 --- /dev/null +++ b/docs/support.html @@ -0,0 +1,41 @@ + + +支持处理 · CodeIsCheap + + +
    +

    Support Operations

    支持处理

    +

    支持包只用于判断运行环境、健康状态和兼容路径。它不是请求导出物,不应包含 Prompt、Raw、请求标识或日志详情。

    + +
    接收原则先在隔离目录验证,再生成无内容摘要;验证失败的文件不得进入工单、聊天或长期存储。
    + +

    接收流程

    +
    用户预览并导出
    →
    隔离目录接收
    →
    格式与秘密扫描
    →
    生成诊断摘要
    →
    分级处理并删除
    +
    python scripts/inspect_support_bundle.py validate path/to/support.json
    +python scripts/inspect_support_bundle.py summarize path/to/support.json
    +python scripts/inspect_support_bundle.py summarize path/to/support.json --json
    +

    工具拒绝超过 512 KiB、非普通文件、重复 JSON 键、未知版本、异常隐私声明、请求内容字段、凭据模式和可写恢复状态。摘要不会输出 endpoint、证书指纹、运行时详情或请求内容。

    + +

    诊断分流

    +
    + + + + + + +
    摘要信号处理升级条件
    gateway_unavailable确认本地端口冲突、运行时启动事件和重试结果同版本可稳定复现或影响历史数据
    proxy_bundle_unavailable / proxy_unavailable核对安装来源与已签名 sidecar;先回退 Gateway正式签名制品校验失败
    certificate_*按 UI 建议检查 CA 状态;不索取私钥,不绕过 pinning用户级安装或卸载破坏系统信任状态
    proxy_capture_unobserved确认应用是否使用系统代理;建议 Gateway 对照代理路径可证实经过但仍无捕获事件
    recovery_read_only保留只读历史并导出必要请求;停止捕获和系统变更主库迁移失败、备份不可读或版本不匹配
    验证器发现凭据或内容字段立即拒收并删除副本,请用户从应用重新生成按安全事件处理,必要时轮换已暴露凭据
    + +

    分级与证据

    +
    +

    P0 / P1

    • 代理残留导致断网
    • 凭据、Prompt 或私钥泄露
    • 升级导致主库不可用且恢复失败
    • 签名或制品完整性异常
    +

    P2 / P3

    • 单应用代理兼容问题
    • 解析降级 Raw 或字段缺失
    • 界面、性能和可访问性问题
    • 不影响捕获的诊断提示
    +
    +

    工单只记录摘要、应用版本、操作系统、复现步骤和处理结论。需要请求样本时,由用户另行生成对应等级的脱敏导出,不得把支持包当作请求数据通道。

    + +

    保留与关闭

    +
    1. 原始支持包仅存放在访问受控的案件目录,默认最多保留 14 天。
    2. 工单使用验证器生成的摘要;不得粘贴原始 JSON 或运行时错误详情。
    3. 案件关闭后删除原始包;安全事件按事件响应策略保留最小证据。
    4. 记录根因、修复版本、回归证据和是否需要更新兼容矩阵。
    + + +
    + diff --git a/scripts/inspect_support_bundle.py b/scripts/inspect_support_bundle.py new file mode 100644 index 0000000..a524eee --- /dev/null +++ b/scripts/inspect_support_bundle.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +import argparse +from functools import lru_cache +import json +import os +from pathlib import Path +import re +import stat +import sys +from typing import Any + + +SUPPORT_BUNDLE_FORMAT_VERSION = "0.1" +SUPPORT_BUNDLE_POLICY_VERSION = "0.1" +MAX_SUPPORT_BUNDLE_BYTES = 512 * 1024 +MAX_DIAGNOSTIC_EVENTS = 100 +MAX_TEXT_LENGTH = 4096 +MAX_DOCUMENT_NODES = 10_000 +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +CREDENTIAL_CORPUS_PATH = ( + REPOSITORY_ROOT / "policies" / "credential-corpus.v0.1.json" +) + +TOP_LEVEL_KEYS = { + "formatVersion", + "policyVersion", + "generatedAtUnixMs", + "product", + "privacy", + "diagnostics", + "redactionCount", +} +PRODUCT_KEYS = { + "name", + "version", + "desktopApiVersion", + "platform", + "architecture", +} +PRIVACY_KEYS = { + "requestContentIncluded", + "requestIdentifiersIncluded", + "rawCaptureIncluded", + "logsIncluded", + "logDetailsIncluded", +} +DIAGNOSTIC_KEYS = { + "source", + "capture", + "certificateAuthority", + "health", + "compatibility", + "runtimeIssue", + "diagnosticEvents", +} +CAPTURE_KEYS = { + "active", + "canControl", + "mode", + "endpoint", + "profile", + "proxyAvailable", + "requestCount", + "storage", +} +CERTIFICATE_KEYS = { + "state", + "trust", + "privateMaterial", + "canManageTrust", + "fingerprintSha256", +} +HEALTH_KEYS = { + "encryptedStore", + "captureRuntime", + "endpointConnected", + "proxyBundle", +} +COMPATIBILITY_KEYS = { + "code", + "status", + "confidence", + "title", + "summary", + "recommendedMode", + "action", + "steps", +} +COMPATIBILITY_STEP_KEYS = {"id", "status", "label", "detail"} +DIAGNOSTIC_EVENT_KEYS = {"occurredAtUnixMs", "code"} +FORBIDDEN_CONTENT_KEYS = { + "captureid", + "instructions", + "messages", + "prompt", + "prompts", + "raw", + "rawcapture", + "request", + "requestbody", + "requestid", + "requests", + "responsebody", + "tools", +} +COMPATIBILITY_CODES = { + "gateway_ready", + "gateway_unavailable", + "proxy_bundle_unavailable", + "proxy_unavailable", + "certificate_missing", + "certificate_invalid", + "certificate_trust_required", + "capture_paused", + "recovery_read_only", + "proxy_capture_unobserved", + "proxy_capture_observed", +} + + +class SupportBundleError(ValueError): + pass + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise SupportBundleError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_support_bundle(path: Path) -> dict[str, Any]: + try: + metadata = path.lstat() + except OSError as error: + raise SupportBundleError(f"bundle could not be inspected: {error}") from error + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise SupportBundleError("bundle must be a regular file, not a symlink") + if metadata.st_size == 0 or metadata.st_size > MAX_SUPPORT_BUNDLE_BYTES: + raise SupportBundleError( + f"bundle size must be between 1 and {MAX_SUPPORT_BUNDLE_BYTES} bytes" + ) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as bundle_file: + opened_metadata = os.fstat(bundle_file.fileno()) + if not stat.S_ISREG(opened_metadata.st_mode): + raise SupportBundleError("bundle must remain a regular file while read") + encoded = bundle_file.read(MAX_SUPPORT_BUNDLE_BYTES + 1) + if not encoded or len(encoded) > MAX_SUPPORT_BUNDLE_BYTES: + raise SupportBundleError( + f"bundle size must be between 1 and {MAX_SUPPORT_BUNDLE_BYTES} bytes" + ) + document = json.loads( + encoded.decode("utf-8"), object_pairs_hook=_reject_duplicate_keys + ) + except OSError as error: + raise SupportBundleError(f"bundle could not be read: {error}") from error + except RecursionError as error: + raise SupportBundleError("bundle JSON nesting is too deep") from error + except UnicodeDecodeError as error: + raise SupportBundleError("bundle must be UTF-8 JSON") from error + except json.JSONDecodeError as error: + raise SupportBundleError(f"bundle is not valid JSON: {error.msg}") from error + if not isinstance(document, dict): + raise SupportBundleError("bundle root must be a JSON object") + validate_support_bundle(document) + return document + + +def validate_support_bundle(document: dict[str, Any]) -> None: + _reject_forbidden_content_fields(document) + _require_exact_keys(document, TOP_LEVEL_KEYS, "bundle") + _require_equal(document, "formatVersion", SUPPORT_BUNDLE_FORMAT_VERSION, "bundle") + _require_equal(document, "policyVersion", SUPPORT_BUNDLE_POLICY_VERSION, "bundle") + _require_positive_integer(document, "generatedAtUnixMs", "bundle") + _require_non_negative_integer(document, "redactionCount", "bundle") + + product = _require_object(document, "product", "bundle") + _require_exact_keys(product, PRODUCT_KEYS, "product") + _require_equal(product, "name", "CodeIsCheap", "product") + for key in ("version", "desktopApiVersion", "platform", "architecture"): + _require_text(product, key, "product", maximum=128) + + privacy = _require_object(document, "privacy", "bundle") + _require_exact_keys(privacy, PRIVACY_KEYS, "privacy") + for key in PRIVACY_KEYS: + _require_boolean(privacy, key, "privacy") + for key in ( + "requestContentIncluded", + "requestIdentifiersIncluded", + "rawCaptureIncluded", + "logDetailsIncluded", + ): + _require_equal(privacy, key, False, "privacy") + + diagnostics = _require_object(document, "diagnostics", "bundle") + _require_exact_keys(diagnostics, DIAGNOSTIC_KEYS, "diagnostics") + source = _require_choice( + diagnostics, + "source", + {"synthetic_fixture", "encrypted_local", "recovery_backup"}, + "diagnostics", + ) + runtime_issue = diagnostics.get("runtimeIssue") + if runtime_issue is not None: + _require_text(diagnostics, "runtimeIssue", "diagnostics") + + capture = _require_object(diagnostics, "capture", "diagnostics") + _require_exact_keys(capture, CAPTURE_KEYS, "diagnostics.capture") + for key in ("active", "canControl", "proxyAvailable"): + _require_boolean(capture, key, "diagnostics.capture") + _require_choice(capture, "mode", {"gateway", "proxy"}, "diagnostics.capture") + for key in ("endpoint", "profile", "storage"): + _require_text(capture, key, "diagnostics.capture") + _require_non_negative_integer(capture, "requestCount", "diagnostics.capture") + + certificate = _require_object( + diagnostics, "certificateAuthority", "diagnostics" + ) + _require_exact_keys( + certificate, CERTIFICATE_KEYS, "diagnostics.certificateAuthority" + ) + _require_choice( + certificate, + "state", + {"missing", "ready", "invalid"}, + "diagnostics.certificateAuthority", + ) + _require_choice( + certificate, + "trust", + {"unchecked", "trusted", "not_trusted", "unsupported"}, + "diagnostics.certificateAuthority", + ) + _require_choice( + certificate, + "privateMaterial", + {"missing", "restricted", "unchecked", "insecure"}, + "diagnostics.certificateAuthority", + ) + _require_boolean( + certificate, "canManageTrust", "diagnostics.certificateAuthority" + ) + fingerprint = certificate.get("fingerprintSha256") + if fingerprint is not None: + fingerprint = _require_text( + certificate, + "fingerprintSha256", + "diagnostics.certificateAuthority", + maximum=95, + ) + if not re.fullmatch(r"[0-9A-Fa-f:]{64,95}", fingerprint): + raise SupportBundleError("certificate fingerprint is invalid") + + health = _require_object(diagnostics, "health", "diagnostics") + _require_exact_keys(health, HEALTH_KEYS, "diagnostics.health") + for key in HEALTH_KEYS: + _require_boolean(health, key, "diagnostics.health") + + compatibility = _require_object(diagnostics, "compatibility", "diagnostics") + _require_exact_keys( + compatibility, COMPATIBILITY_KEYS, "diagnostics.compatibility" + ) + _require_choice( + compatibility, + "code", + COMPATIBILITY_CODES, + "diagnostics.compatibility", + ) + _require_choice( + compatibility, + "status", + {"ready", "attention", "blocked"}, + "diagnostics.compatibility", + ) + _require_choice( + compatibility, + "confidence", + {"high", "low"}, + "diagnostics.compatibility", + ) + _require_choice( + compatibility, + "recommendedMode", + {"gateway", "proxy"}, + "diagnostics.compatibility", + ) + _require_choice( + compatibility, + "action", + {"none", "resume_capture", "trust_certificate", "use_gateway"}, + "diagnostics.compatibility", + ) + for key in ("title", "summary"): + _require_text(compatibility, key, "diagnostics.compatibility") + steps = _require_list(compatibility, "steps", "diagnostics.compatibility") + if len(steps) > 16: + raise SupportBundleError("compatibility steps exceed the supported limit") + for index, step in enumerate(steps): + context = f"diagnostics.compatibility.steps[{index}]" + if not isinstance(step, dict): + raise SupportBundleError(f"{context} must be an object") + _require_exact_keys(step, COMPATIBILITY_STEP_KEYS, context) + for key in ("id", "label", "detail"): + _require_text(step, key, context) + _require_choice( + step, "status", {"pass", "attention", "blocked", "pending"}, context + ) + + events = _require_list(diagnostics, "diagnosticEvents", "diagnostics") + if len(events) > MAX_DIAGNOSTIC_EVENTS: + raise SupportBundleError("diagnostic event count exceeds the supported limit") + for index, event in enumerate(events): + context = f"diagnostics.diagnosticEvents[{index}]" + if not isinstance(event, dict): + raise SupportBundleError(f"{context} must be an object") + _require_exact_keys(event, DIAGNOSTIC_EVENT_KEYS, context) + _require_positive_integer(event, "occurredAtUnixMs", context) + code = _require_text(event, "code", context, maximum=64) + if not re.fullmatch(r"[a-z0-9_]+", code): + raise SupportBundleError(f"{context}.code is invalid") + if privacy["logsIncluded"] != bool(events): + raise SupportBundleError( + "privacy.logsIncluded must match the diagnostic event collection" + ) + + if source == "recovery_backup" and ( + capture["active"] or capture["canControl"] + ): + raise SupportBundleError("recovery bundles must report read-only capture state") + + _scan_credentials(document) + + +def summarize_support_bundle(document: dict[str, Any]) -> dict[str, Any]: + validate_support_bundle(document) + diagnostics = document["diagnostics"] + compatibility = diagnostics["compatibility"] + capture = diagnostics["capture"] + return { + "formatVersion": document["formatVersion"], + "generatedAtUnixMs": document["generatedAtUnixMs"], + "product": document["product"], + "source": diagnostics["source"], + "compatibility": { + "code": compatibility["code"], + "status": compatibility["status"], + "confidence": compatibility["confidence"], + "action": compatibility["action"], + }, + "capture": { + "mode": capture["mode"], + "active": capture["active"], + "canControl": capture["canControl"], + "proxyAvailable": capture["proxyAvailable"], + "requestCount": capture["requestCount"], + }, + "health": diagnostics["health"], + "diagnosticEvents": diagnostics["diagnosticEvents"], + "redactionCount": document["redactionCount"], + } + + +def format_summary(summary: dict[str, Any]) -> str: + product = summary["product"] + compatibility = summary["compatibility"] + capture = summary["capture"] + health = summary["health"] + event_codes = [event["code"] for event in summary["diagnosticEvents"]] + lines = [ + f"CodeIsCheap {product['version']} on {product['platform']}/{product['architecture']}", + f"Source: {summary['source']}", + ( + "Compatibility: " + f"{compatibility['status']} / {compatibility['code']} " + f"({compatibility['confidence']} confidence, action={compatibility['action']})" + ), + ( + f"Capture: mode={capture['mode']}, active={str(capture['active']).lower()}, " + f"controllable={str(capture['canControl']).lower()}, " + f"proxyBundle={str(capture['proxyAvailable']).lower()}, " + f"storedRequests={capture['requestCount']}" + ), + "Health: " + + ", ".join( + f"{key}={str(value).lower()}" for key, value in sorted(health.items()) + ), + "Diagnostic events: " + (", ".join(event_codes) if event_codes else "none"), + f"Redactions applied before intake: {summary['redactionCount']}", + ] + return "\n".join(lines) + + +def _require_exact_keys(value: dict[str, Any], expected: set[str], context: str) -> None: + actual = set(value) + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing or extra: + details = [] + if missing: + details.append(f"missing {', '.join(missing)}") + if extra: + details.append(f"unknown {', '.join(extra)}") + raise SupportBundleError(f"{context} fields are invalid: {'; '.join(details)}") + + +def _require_object(value: dict[str, Any], key: str, context: str) -> dict[str, Any]: + result = value.get(key) + if not isinstance(result, dict): + raise SupportBundleError(f"{context}.{key} must be an object") + return result + + +def _require_list(value: dict[str, Any], key: str, context: str) -> list[Any]: + result = value.get(key) + if not isinstance(result, list): + raise SupportBundleError(f"{context}.{key} must be an array") + return result + + +def _require_text( + value: dict[str, Any], + key: str, + context: str, + *, + maximum: int = MAX_TEXT_LENGTH, +) -> str: + result = value.get(key) + if not isinstance(result, str) or not result or len(result) > maximum: + raise SupportBundleError( + f"{context}.{key} must be a non-empty string of at most {maximum} characters" + ) + return result + + +def _require_boolean(value: dict[str, Any], key: str, context: str) -> bool: + result = value.get(key) + if not isinstance(result, bool): + raise SupportBundleError(f"{context}.{key} must be a boolean") + return result + + +def _require_positive_integer(value: dict[str, Any], key: str, context: str) -> int: + result = value.get(key) + if isinstance(result, bool) or not isinstance(result, int) or result <= 0: + raise SupportBundleError(f"{context}.{key} must be a positive integer") + return result + + +def _require_non_negative_integer( + value: dict[str, Any], key: str, context: str +) -> int: + result = value.get(key) + if isinstance(result, bool) or not isinstance(result, int) or result < 0: + raise SupportBundleError(f"{context}.{key} must be a non-negative integer") + return result + + +def _require_choice( + value: dict[str, Any], + key: str, + choices: set[str], + context: str, +) -> str: + result = value.get(key) + if not isinstance(result, str) or result not in choices: + raise SupportBundleError(f"{context}.{key} is unsupported") + return result + + +def _require_equal( + value: dict[str, Any], key: str, expected: Any, context: str +) -> None: + if value.get(key) != expected: + raise SupportBundleError(f"{context}.{key} must equal {expected!r}") + + +def _normalize_field_name(value: str) -> str: + return "".join(character for character in value.lower() if character.isalnum()) + + +def _reject_forbidden_content_fields(value: Any, pointer: str = "") -> None: + for child_pointer, child, key in _walk_document(value, pointer): + if key is not None: + if _normalize_field_name(key) in FORBIDDEN_CONTENT_KEYS: + raise SupportBundleError( + f"request content field is forbidden in support bundles: {child_pointer}" + ) + + +@lru_cache(maxsize=1) +def _credential_patterns() -> tuple[tuple[str, re.Pattern[str]], ...]: + corpus = json.loads(CREDENTIAL_CORPUS_PATH.read_text(encoding="utf-8")) + if corpus.get("version") != SUPPORT_BUNDLE_POLICY_VERSION: + raise SupportBundleError("credential corpus version is unsupported") + return tuple( + (entry["category"], re.compile(entry["expression"])) + for entry in corpus["text_patterns"] + ) + + +def _scan_credentials(value: Any, pointer: str = "") -> None: + for child_pointer, child, _ in _walk_document(value, pointer): + if isinstance(child, str): + for category, pattern in _credential_patterns(): + if not pattern.search(child): + continue + raise SupportBundleError( + f"credential pattern {category} detected at {child_pointer or '/'}" + ) + + +def _walk_document( + value: Any, pointer: str = "" +) -> list[tuple[str, Any, str | None]]: + result: list[tuple[str, Any, str | None]] = [] + stack: list[tuple[str, Any, str | None]] = [(pointer, value, None)] + while stack: + child_pointer, child, key = stack.pop() + result.append((child_pointer, child, key)) + if len(result) > MAX_DOCUMENT_NODES: + raise SupportBundleError("bundle contains too many JSON values") + if isinstance(child, dict): + for child_key, nested in reversed(tuple(child.items())): + stack.append((f"{child_pointer}/{child_key}", nested, child_key)) + elif isinstance(child, list): + for index in range(len(child) - 1, -1, -1): + stack.append((f"{child_pointer}/{index}", child[index], None)) + return result + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Validate and summarize a CodeIsCheap support bundle without exposing request content." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + validate = subparsers.add_parser("validate", help="reject unsafe or incompatible bundles") + validate.add_argument("bundle", type=Path) + summarize = subparsers.add_parser( + "summarize", help="emit a content-free triage summary" + ) + summarize.add_argument("bundle", type=Path) + summarize.add_argument("--json", action="store_true", help="emit JSON") + return parser + + +def main(argv: list[str] | None = None) -> int: + arguments = _parser().parse_args(argv) + try: + document = load_support_bundle(arguments.bundle) + if arguments.command == "validate": + print("support bundle is valid") + else: + summary = summarize_support_bundle(document) + if arguments.json: + print(json.dumps(summary, indent=2, sort_keys=True)) + else: + print(format_summary(summary)) + except (OSError, SupportBundleError) as error: + print(f"support bundle rejected: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_documentation.py b/scripts/tests/test_documentation.py new file mode 100644 index 0000000..06242c2 --- /dev/null +++ b/scripts/tests/test_documentation.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections import Counter +from html.parser import HTMLParser +from pathlib import Path +import unittest +from urllib.parse import unquote + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +DOCUMENTATION_ROOT = REPOSITORY_ROOT / "docs" + + +class DocumentationParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: list[str] = [] + self.ids: list[str] = [] + self.h1_count = 0 + self.main_count = 0 + self.nav_count = 0 + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.ids.append(element_id) + href = attributes.get("href") + if tag == "a" and href: + self.links.append(href) + if tag == "h1": + self.h1_count += 1 + elif tag == "main": + self.main_count += 1 + elif tag == "nav": + self.nav_count += 1 + + +class DocumentationTests(unittest.TestCase): + def test_pages_have_landmarks_unique_ids_and_valid_local_links(self) -> None: + parsers: dict[str, DocumentationParser] = {} + for path in sorted(DOCUMENTATION_ROOT.glob("*.html")): + parser = DocumentationParser() + parser.feed(path.read_text(encoding="utf-8")) + parsers[path.name] = parser + self.assertEqual(parser.h1_count, 1, path) + self.assertEqual(parser.main_count, 1, path) + self.assertEqual(parser.nav_count, 1, path) + duplicate_ids = [ + element_id + for element_id, count in Counter(parser.ids).items() + if count > 1 + ] + self.assertEqual(duplicate_ids, [], path) + + self.assertIn("support.html", parsers) + for source_name, parser in parsers.items(): + for href in parser.links: + if href.startswith(("http://", "https://", "mailto:")): + continue + target, _, fragment = href.partition("#") + target_name = unquote(target) if target else source_name + target_path = DOCUMENTATION_ROOT / target_name + self.assertTrue(target_path.is_file(), f"{source_name}: {href}") + if fragment and target_name in parsers: + self.assertIn( + fragment, + parsers[target_name].ids, + f"{source_name}: {href}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_inspect_support_bundle.py b/scripts/tests/test_inspect_support_bundle.py new file mode 100644 index 0000000..b41c475 --- /dev/null +++ b/scripts/tests/test_inspect_support_bundle.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import copy +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) + +from inspect_support_bundle import ( + MAX_SUPPORT_BUNDLE_BYTES, + SupportBundleError, + format_summary, + load_support_bundle, + summarize_support_bundle, + validate_support_bundle, +) + + +def support_bundle() -> dict[str, object]: + return { + "formatVersion": "0.1", + "policyVersion": "0.1", + "generatedAtUnixMs": 1_784_000_000_000, + "product": { + "name": "CodeIsCheap", + "version": "0.1.0", + "desktopApiVersion": "0.1", + "platform": "windows", + "architecture": "x86_64", + }, + "privacy": { + "requestContentIncluded": False, + "requestIdentifiersIncluded": False, + "rawCaptureIncluded": False, + "logsIncluded": True, + "logDetailsIncluded": False, + }, + "diagnostics": { + "source": "encrypted_local", + "capture": { + "active": True, + "canControl": True, + "mode": "gateway", + "endpoint": "127.0.0.1:8787", + "profile": "Local gateway", + "proxyAvailable": True, + "requestCount": 4, + "storage": "SQLCipher 4 / WAL", + }, + "certificateAuthority": { + "state": "ready", + "trust": "trusted", + "privateMaterial": "restricted", + "canManageTrust": True, + "fingerprintSha256": "a" * 64, + }, + "health": { + "encryptedStore": True, + "captureRuntime": True, + "endpointConnected": True, + "proxyBundle": True, + }, + "compatibility": { + "code": "gateway_ready", + "status": "ready", + "confidence": "high", + "title": "Gateway capture ready", + "summary": "Route the target client to the local Gateway endpoint.", + "recommendedMode": "gateway", + "action": "none", + "steps": [ + { + "id": "gateway_runtime", + "status": "pass", + "label": "Local Gateway", + "detail": "Ready", + } + ], + }, + "runtimeIssue": None, + "diagnosticEvents": [ + { + "occurredAtUnixMs": 1_784_000_000_001, + "code": "sidecar_process_exited", + } + ], + }, + "redactionCount": 0, + } + + +class SupportBundleInspectionTests(unittest.TestCase): + def test_cli_validates_and_emits_a_content_free_summary(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "support.json" + path.write_text(json.dumps(support_bundle()), encoding="utf-8") + validate = subprocess.run( + [ + sys.executable, + str(SCRIPTS / "inspect_support_bundle.py"), + "validate", + str(path), + ], + check=True, + capture_output=True, + text=True, + ) + summary = subprocess.run( + [ + sys.executable, + str(SCRIPTS / "inspect_support_bundle.py"), + "summarize", + str(path), + ], + check=True, + capture_output=True, + text=True, + ) + + self.assertIn("support bundle is valid", validate.stdout) + self.assertIn("gateway_ready", summary.stdout) + self.assertNotIn("127.0.0.1", summary.stdout) + self.assertNotIn("fingerprint", summary.stdout.lower()) + + def test_validates_and_summarizes_only_triage_fields(self) -> None: + bundle = support_bundle() + validate_support_bundle(bundle) + summary = summarize_support_bundle(bundle) + rendered = format_summary(summary) + + self.assertEqual(summary["compatibility"]["code"], "gateway_ready") + self.assertIn("sidecar_process_exited", rendered) + self.assertNotIn("127.0.0.1", rendered) + self.assertNotIn("fingerprint", rendered.lower()) + self.assertNotIn("runtimeIssue", summary) + + def test_rejects_request_content_and_credentials(self) -> None: + with self.assertRaisesRegex(SupportBundleError, "request content field"): + bundle = support_bundle() + bundle["diagnostics"]["prompt"] = "private prompt" + validate_support_bundle(bundle) + + with self.assertRaisesRegex(SupportBundleError, "credential pattern"): + bundle = support_bundle() + bundle["diagnostics"]["runtimeIssue"] = ( + "upstream rejected Bearer abcdefghijklmnopqrstuvwxyz123456" + ) + validate_support_bundle(bundle) + + def test_rejects_privacy_mismatch_recovery_control_and_unknown_format(self) -> None: + cases = [] + + privacy = support_bundle() + privacy["privacy"]["requestContentIncluded"] = True + cases.append(privacy) + + recovery = support_bundle() + recovery["diagnostics"]["source"] = "recovery_backup" + cases.append(recovery) + + version = support_bundle() + version["formatVersion"] = "0.2" + cases.append(version) + + logs = support_bundle() + logs["privacy"]["logsIncluded"] = False + cases.append(logs) + + for bundle in cases: + with self.subTest(bundle=bundle), self.assertRaises(SupportBundleError): + validate_support_bundle(bundle) + + def test_file_loader_rejects_duplicates_and_oversized_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + valid = root / "support.json" + valid.write_text(json.dumps(support_bundle()), encoding="utf-8") + self.assertEqual(load_support_bundle(valid)["formatVersion"], "0.1") + + duplicate = root / "duplicate.json" + duplicate.write_text('{"formatVersion":"0.1","formatVersion":"0.1"}', encoding="utf-8") + with self.assertRaisesRegex(SupportBundleError, "duplicate JSON key"): + load_support_bundle(duplicate) + + oversized = root / "oversized.json" + oversized.write_bytes(b" " * (MAX_SUPPORT_BUNDLE_BYTES + 1)) + with self.assertRaisesRegex(SupportBundleError, "bundle size"): + load_support_bundle(oversized) + + deeply_nested = root / "deep.json" + deeply_nested.write_text("[" * 1_100 + "0" + "]" * 1_100, encoding="utf-8") + with self.assertRaises(SupportBundleError): + load_support_bundle(deeply_nested) + + def test_rejects_documents_with_excessive_json_nodes(self) -> None: + bundle = support_bundle() + bundle["unexpected"] = [None] * 10_001 + with self.assertRaisesRegex(SupportBundleError, "too many JSON values"): + validate_support_bundle(bundle) + + def test_recovery_bundle_is_accepted_when_controls_are_disabled(self) -> None: + bundle = copy.deepcopy(support_bundle()) + bundle["diagnostics"]["source"] = "recovery_backup" + bundle["diagnostics"]["capture"]["active"] = False + bundle["diagnostics"]["capture"]["canControl"] = False + bundle["diagnostics"]["compatibility"]["code"] = "recovery_read_only" + bundle["diagnostics"]["compatibility"]["status"] = "attention" + validate_support_bundle(bundle) + + +if __name__ == "__main__": + unittest.main() From f29c98cc3fad7c840c310bd3f2ccbcf6fde75ea9 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 21 Jul 2026 20:46:32 +0800 Subject: [PATCH 2/2] fix: restore proxy on macos helper disconnect --- crates/proxy-recovery/src/macos_privileged.rs | 12 +++++++++++- docs/progress.html | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/proxy-recovery/src/macos_privileged.rs b/crates/proxy-recovery/src/macos_privileged.rs index aa6099c..56236a5 100644 --- a/crates/proxy-recovery/src/macos_privileged.rs +++ b/crates/proxy-recovery/src/macos_privileged.rs @@ -337,7 +337,17 @@ pub fn run_macos_proxy_helper_session( let _ = fs::remove_file(status_path); stream.set_read_timeout(None)?; - let command: Option = read_frame(&mut stream)?; + // After attachment, a transport error means the owner channel is gone and must restore. + let command: Option = match read_frame(&mut stream) { + Ok(command) => command, + Err(RecoveryError::Io(_)) => None, + Err(error) => { + return match session.restore() { + Ok(()) => Err(error), + Err(restore_error) => Err(restore_error), + }; + } + }; match command { None => session.restore(), Some(command) if validate_command(&command, ControlCommand::Restore).is_ok() => { diff --git a/docs/progress.html b/docs/progress.html index 81d7ddc..8b21ca2 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -237,6 +237,7 @@

    11. 决策与变更记录

    2026-07-21Release / UpdaterREL-001 / SEC-004 签名更新入口完成首段Tauri updater 使用编译时可信公钥和固定 HTTPS GitHub Release endpoint;安装前重新检查版本、恢复系统代理、切回 Gateway,并建立可验证恢复的 SQLCipher 备份和版本化 journal;设置页显示可用版本、说明和下载进度,未配置公钥时失败关闭Codex 2026-07-21Security / DependenciesSEC-004 兼容漏洞修复npm diff 升至 8.0.3,两份 Cargo lock 的 time 升至 0.3.47,桌面 serde_with 升至 3.21.0,并将声明 MSRV 提升到依赖要求的 Rust 1.88;glib 告警来自最新版 Tauri 的 Linux GTK3 链,仓库及 Tauri/Wry 未调用受影响 VariantStrIter,单独按 not-used 证据处置Codex 2026-07-21Release / SupportREL-002 支持处理闭环完成接收端工具严格验证支持包格式、隐私不变量、文件属性、大小、重复键、内容字段和共享凭据 corpus,只输出不含 endpoint、指纹、运行时详情与请求内容的摘要;支持分流、升级、14 天保留和案件关闭删除流程已发布Codex + 2026-07-21Capture / RecoverymacOS 26 owner 断连恢复兼容认证后的 helper 控制 socket 在 peer 关闭时可能返回 EINVAL;所有传输层断连现统一进入显式代理恢复并正常结束,畸形协议和 JSON 仍恢复后报错Codex 后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。