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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,54 @@ describe("request workbench", () => {
expect(screen.getByRole("button", { name: "Remove trust" })).toBeInTheDocument();
});

it("keeps CA trust retryable after the user rejects the system prompt", async () => {
const user = userEvent.setup();
window.__TAURI_INTERNALS__ = {};
const untrusted = structuredClone(fixture) as unknown as WorkspaceBootstrap;
untrusted.capture.mode = "proxy";
untrusted.capture.profile = "Explicit TLS proxy";
untrusted.capture.endpoint = "http://127.0.0.1:43125";
untrusted.capture.certificateAuthority = {
state: "ready",
canManageTrust: true,
fingerprintSha256: "AA:BB:CC:DD",
subject: "mitmproxy",
validFromUnixMs: 1_577_836_800_000,
validUntilUnixMs: 4_070_908_800_000,
privateMaterial: "restricted",
trust: "not_trusted",
detail: null,
};
const trusted = structuredClone(untrusted);
trusted.capture.certificateAuthority.trust = "trusted";
let installAttempts = 0;
vi.mocked(invoke).mockImplementation(async (command) => {
if (command === "bootstrap_workspace") return structuredClone(untrusted);
if (command === "install_certificate_authority_trust") {
installAttempts += 1;
if (installAttempts === 1) {
throw new Error("certificate trust update failed: the user cancelled the security prompt");
}
return structuredClone(trusted);
}
throw new Error(`Unexpected command: ${command}`);
});

render(<App />);
await user.click(await screen.findByRole("button", { name: "Trust CA" }));

expect(await screen.findByRole("alert")).toHaveTextContent("user cancelled");
expect(screen.getByText("Ready · not trusted")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Proxy" })).toHaveAttribute("aria-pressed", "true");
const retry = screen.getByRole("button", { name: "Trust CA" });
expect(retry).toBeEnabled();

await user.click(retry);
expect(await screen.findByText("Ready · trusted")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove trust" })).toBeInTheDocument();
expect(installAttempts).toBe(2);
});

it("removes certificate trust and accepts the safe gateway fallback", async () => {
const user = userEvent.setup();
window.__TAURI_INTERNALS__ = {};
Expand Down
91 changes: 81 additions & 10 deletions crates/sidecar-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,19 +810,46 @@ fn macos_install_certificate(certificate_der: &[u8]) -> io::Result<bool> {
(certificate, added)
}
};
if let Err(error) = settings.set_trust_settings_always(&certificate) {
if added_to_keychain {
let _ = certificate.delete();
}
return Err(macos_security_error(
"set user certificate trust",
error.code(),
));
}
set_macos_trust_with_new_certificate_cleanup(
added_to_keychain,
|| {
settings
.set_trust_settings_always(&certificate)
.map_err(|error| macos_security_error("set user certificate trust", error.code()))
},
|| match certificate.delete() {
Ok(()) => Ok(()),
Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()),
Err(error) => Err(macos_security_error(
"remove rejected certificate from login keychain",
error.code(),
)),
},
)?;
macos_wait_for_user_trust(certificate_der, Some(CertificateTrustState::Trusted))?;
Ok(true)
}

#[cfg(any(target_os = "macos", test))]
fn set_macos_trust_with_new_certificate_cleanup(
added_to_keychain: bool,
set_trust: impl FnOnce() -> io::Result<()>,
remove_new_certificate: impl FnOnce() -> io::Result<()>,
) -> io::Result<()> {
let Err(trust_error) = set_trust() else {
return Ok(());
};
if !added_to_keychain {
return Err(trust_error);
}
match remove_new_certificate() {
Ok(()) => Err(trust_error),
Err(cleanup_error) => Err(io::Error::other(format!(
"{trust_error}; newly added certificate cleanup failed: {cleanup_error}"
))),
}
}

#[cfg(target_os = "macos")]
fn macos_uninstall_certificate(certificate_der: &[u8]) -> io::Result<bool> {
let user_certificate = macos_domain_certificate_trust(Domain::User, certificate_der)?;
Expand Down Expand Up @@ -879,7 +906,7 @@ fn macos_wait_for_user_trust(
}
}

#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", test))]
fn macos_security_error(action: &str, code: i32) -> io::Error {
let detail = match code {
-128 | -60_006 => "the user cancelled the security prompt",
Expand Down Expand Up @@ -1963,6 +1990,50 @@ MAoGCCqGSM49BAMCA0gAMEUCIQC1PB8+NumezrQf5unFGhVeufUcyw/sjH6p1aqs
));
}

#[test]
fn macos_ca_refusal_cleans_new_certificate_and_reports_cleanup_failure() {
use std::cell::Cell;

let removed = Cell::new(false);
let error = set_macos_trust_with_new_certificate_cleanup(
true,
|| Err(macos_security_error("set user certificate trust", -128)),
|| {
removed.set(true);
Ok(())
},
)
.expect_err("cancelled trust must fail");
assert!(removed.get());
assert!(error.to_string().contains("user cancelled"));

removed.set(false);
let _ = set_macos_trust_with_new_certificate_cleanup(
false,
|| Err(macos_security_error("set user certificate trust", -128)),
|| {
removed.set(true);
Ok(())
},
)
.expect_err("pre-existing certificate trust refusal must fail");
assert!(
!removed.get(),
"pre-existing certificates must be preserved"
);

let error = set_macos_trust_with_new_certificate_cleanup(
true,
|| Err(macos_security_error("set user certificate trust", -60_006)),
|| Err(io::Error::other("fault-injected keychain cleanup refusal")),
)
.expect_err("failed cleanup must remain actionable");
let detail = error.to_string();
assert!(detail.contains("user cancelled"));
assert!(detail.contains("newly added certificate cleanup failed"));
assert!(detail.contains("fault-injected keychain cleanup refusal"));
}

#[cfg(windows)]
#[test]
fn windows_certificate_store_lifecycle_is_exact_and_idempotent() {
Expand Down
5 changes: 3 additions & 2 deletions docs/progress.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ <h2 id="current-sprint">5. 当前迭代:S5 / S6</h2>
<tr><td class="task-id">PAR-007</td><td>token、成本和语义指纹</td><td><span class="status done">Done</span></td><td>100%</td><td>Codex / TBD</td><td>2026-07-17</td><td>四厂商 reported usage 归一化、显式 estimated 估算、版本化价格匹配、未知价格留空与 BLAKE3-256 语义指纹完成</td></tr>
<tr><td class="task-id">TST-001</td><td>协议 fixture 与 golden tests</td><td><span class="status done">Done</span></td><td>100%</td><td>Codex / TBD</td><td>2026-07-17</td><td>版本化能力矩阵覆盖 OpenAI、Anthropic、Gemini 与 Ollama 的请求、响应、流式、工具、多模态、错误和 Raw fallback;声明均由 fixture 与 golden 验证</td></tr>
<tr><td class="task-id">TST-002</td><td>Gateway/Proxy 集成测试</td><td><span class="status active">In progress</span></td><td>82%</td><td>Codex / TBD</td><td>2026-08-25</td><td>认证 IPC、暂停丢弃、真实模式切换与进程树清理、压缩、流式脱敏、非目标 TLS、HTTP/2 基线一致性及 Windows CA 精确增删通过;真实 sidecar 在客户端取消后保持存活,IPC 停止消费且 72 个事件超过容量 64 时,36 个目标响应仍在 15 秒内完成;真实 ROOT 往返和其余协议一致性待扩展</td></tr>
<tr><td class="task-id">TST-003</td><td>平台故障注入</td><td><span class="status active">In progress</span></td><td>70%</td><td>Codex / TBD</td><td>2026-08-25</td><td>Windows/macOS 强杀恢复、活 owner 防误恢复、sidecar 独立退出回退和端口冲突拒绝已通过;PAC 原状态下应用与同步回滚双重失败会交由独立 watchdog 恢复;readiness token 不复用 IPC token,错误 listener 与旧 addon 不会触发系统代理切换;CA 拒绝与 macOS helper 故障矩阵待完成</td></tr>
<tr><td class="task-id">TST-003</td><td>平台故障注入</td><td><span class="status active">In progress</span></td><td>75%</td><td>Codex / TBD</td><td>2026-08-25</td><td>Windows/macOS 强杀恢复、活 owner 防误恢复、sidecar 独立退出回退和端口冲突拒绝已通过;PAC 原状态下应用与同步回滚双重失败会交由独立 watchdog 恢复;macOS 用户拒绝 CA 信任时清理本次新增证书,清理失败返回双重错误,桌面保持 not trusted 并允许重试;macOS helper 故障矩阵待完成</td></tr>
<tr><td class="task-id">REL-002</td><td>诊断与支持包</td><td><span class="status active">In progress</span></td><td>75%</td><td>Codex / Support Owner TBD</td><td>2026-09-22</td><td>版本化 JSON 支持包可预览、复制和保存,包含不带请求标识的兼容诊断树;256 KiB code-only journal 与最近 100 条事件接入,排除 Prompt、Raw 和日志详情;支持处理流程待完成</td></tr>
<tr><td class="task-id">SPIKE-001</td><td>Gateway 流式透明转发验证</td><td><span class="status done">Done</span></td><td>100%</td><td>Codex / TBD</td><td>2026-07-14</td><td>双向流式、取消传递、头清理与稳定 502 集成测试通过</td></tr>
<tr><td class="task-id">SPIKE-002</td><td>mitmproxy sidecar IPC/打包验证</td><td><span class="status done">Done</span></td><td>100%</td><td>Codex / TBD</td><td>2026-07-14</td><td><a href="spikes/mitmproxy-sidecar.html">凭据清理、IPC、打包与真实转发通过</a></td></tr>
Expand All @@ -123,7 +123,7 @@ <h2 id="backlog">6. 后续迭代承诺</h2>
<tr><td>S2</td><td>CAP-001/002、PAR-002/003、APP-003</td><td><span class="status done">Done</span></td><td>Gateway、OpenAI 解析与千条实时工作台全部通过验收</td></tr>
<tr><td>S3</td><td>PAR-004、APP-004、TST-001、DAT-002</td><td><span class="status done">Done</span></td><td>双厂商 Inspector、能力矩阵与数据生命周期全部通过验收</td></tr>
<tr><td>S4</td><td>CAP-003~005、TST-002</td><td><span class="status active">In progress</span></td><td>sidecar bundle、桌面运行时、协议矩阵、Proxy 取消/背压、跨平台 CA 状态及两平台用户级信任生命周期已实现;签名、其余协议一致性与交互式验收待推进</td></tr>
<tr><td>S5</td><td>CAP-006/007、SEC-002/003、APP-006、TST-003</td><td><span class="status active">In progress</span></td><td>SEC-002、CAP-007 完成,IPC 抗阻塞、双模式 OS PID 归因、readiness 身份校验和兼容诊断树已接入;继续 macOS helper、OS socket ACL、PAC 与 CA 拒绝故障注入</td></tr>
<tr><td>S5</td><td>CAP-006/007、SEC-002/003、APP-006、TST-003</td><td><span class="status active">In progress</span></td><td>SEC-002、CAP-007 完成,IPC 抗阻塞、双模式 OS PID 归因、readiness 身份校验、PAC/CA 拒绝恢复和兼容诊断树已接入;继续 macOS helper 与 OS socket ACL</td></tr>
<tr><td>S6</td><td>PAR-005~007、APP-005</td><td><span class="status done">Done</span></td><td>四厂商适配器、token/成本/指纹、全文搜索和结构/文本 Compare 全部完成</td></tr>
<tr><td>S7</td><td>TST-004、性能、可访问性、诊断与保留</td><td><span class="status">Not started</span></td><td>功能冻结</td></tr>
<tr><td>S8</td><td>SEC-004、REL-001/002、安全评审</td><td><span class="status active">In progress</span></td><td>code-only 诊断与支持包已接入;签名更新、安装回滚、支持流程与安全评审待推进</td></tr>
Expand Down Expand Up @@ -222,6 +222,7 @@ <h2 id="decisions">11. 决策与变更记录</h2>
<tr><td>2026-07-18</td><td>Test / Proxy</td><td>TST-002 取消与背压矩阵完成</td><td>单元测试以阻塞 IPC worker 和容量 1 队列证明满队列 submit 立即失败;首次 IPC 失败会清空 backlog、熔断 10 秒并自动探测恢复;真实打包 sidecar 在 IPC listener 停止消费后承受 36 个目标请求、72 个捕获事件和一次客户端取消,转发响应保持一致且进程继续存活;两个结论成为 Python 与 Rust bundle 强制合同</td><td>Codex</td></tr>
<tr><td>2026-07-18</td><td>Security / Recovery</td><td>TST-003 sidecar 端口冲突关闭</td><td>桌面为每次启动生成与 IPC token 隔离的 256-bit readiness token;打包 addon 在保留域名内部终止版本化探针,Rust runtime 常量时间核对响应后才判定 ready;普通 listener、错误 token、旧 addon 和端口抢占均失败并清理进程树,系统代理不会切换到未知进程</td><td>Codex</td></tr>
<tr><td>2026-07-18</td><td>Recovery / Fault injection</td><td>TST-003 PAC 双重失败恢复完成</td><td>代理应用部分写入且进程内回滚失败时不再删除 armed journal;owner 主动关闭控制通道并等待独立 watchdog 按预加载 PAC 快照恢复,独立恢复也失败时保留 journal 并返回完整三段错误</td><td>Codex</td></tr>
<tr><td>2026-07-18</td><td>Certificate / Fault injection</td><td>TST-003 CA 拒绝恢复完成</td><td>macOS 用户拒绝信任时删除本次刚加入登录钥匙串的证书;删除也失败时同时报告拒绝与残留清理错误;桌面保持 not trusted、显示原因并允许用户再次触发系统信任流程</td><td>Codex</td></tr>
<tr><td colspan="5">后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。</td></tr>
</tbody></table></div>

Expand Down
4 changes: 2 additions & 2 deletions docs/threat-model.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ <h2 id="threats">威胁台账</h2>
<tr><td class="task-id">TM-02</td><td>捕获非目标应用或端点</td><td>Rust/Python 共用精确 host、POST method 与 path pattern 策略</td><td>Profile UI 与自定义端点策略管理</td></tr>
<tr><td class="task-id">TM-03</td><td>本机其他进程伪造、读取或阻塞 IPC</td><td>IPC 0.4 仅限 loopback;每次 Proxy 会话生成 256-bit token 并常量时间比较;Windows/macOS/Linux 首连接通过 OS socket 表绑定已启动 sidecar PID,Linux 仅接受精确 socket inode 的唯一 owner;sidecar 在服务端 ACK 前保持连接;认证帧 1 KiB、Envelope 4 MiB;要求 mitmproxy 来源声明;连接全流程 2 秒未完成即释放;临时归因端点严格限制为同地址族 loopback 且不进入 Envelope;旧协议 bundle 启动前拒绝</td><td>用户级 socket ACL;插件启用后的 WASI 最小权限</td></tr>
<tr><td class="task-id">TM-04</td><td>数据库文件被离线读取</td><td>SQLCipher、WAL、迁移、加密备份、错误密钥、DB/WAL canary 与跨平台 OS 凭据库 CI</td><td>锁定状态与 key 丢失恢复体验</td></tr>
<tr><td class="task-id">TM-05</td><td>本地 CA 私钥泄露或残留信任</td><td>每设备 CA、跨平台私钥权限检查、指纹 UI、Windows CurrentUser 与 macOS User 幂等卸载已实现</td><td>两平台交互式验收;发布前独立安全评审</td></tr>
<tr><td class="task-id">TM-06</td><td>崩溃后系统代理残留</td><td>Windows/macOS 强杀恢复实验;Windows 桌面已接入私有 journal、watchdog、启动恢复和活 owner 校验;sidecar 单独退出会按代际恢复代理并回退 Gateway;端口占用者无法通过独立 256-bit readiness token 校验,冲突启动会清理进程树且不会切换系统代理</td><td>macOS 特权 helper;PAC 与 CA 拒绝故障注入</td></tr>
<tr><td class="task-id">TM-05</td><td>本地 CA 私钥泄露或残留信任</td><td>每设备 CA、跨平台私钥权限检查、指纹 UI、Windows CurrentUser 与 macOS User 幂等卸载已实现;macOS 信任拒绝会清理本次新增证书,桌面保持未信任并允许重试</td><td>两平台交互式验收;发布前独立安全评审</td></tr>
<tr><td class="task-id">TM-06</td><td>崩溃后系统代理残留</td><td>Windows/macOS 强杀恢复实验;Windows 桌面已接入私有 journal、watchdog、启动恢复和活 owner 校验;sidecar 单独退出会按代际恢复代理并回退 Gateway;端口占用者无法通过独立 256-bit readiness token 校验,冲突启动会清理进程树且不会切换系统代理;PAC 双重失败由 watchdog 恢复</td><td>macOS 特权 helper</td></tr>
<tr><td class="task-id">TM-07</td><td>sidecar 或更新包被替换</td><td>打包 manifest 含 SHA-256</td><td>签名、SBOM、启动前 hash 校验;发布硬门槛</td></tr>
<tr><td class="task-id">TM-08</td><td>复制或导出泄露秘密</td><td>单请求、最多 200 条批量导出及支持包均强制扫描与完整预览;版本化 corpus 覆盖 36 个敏感字段名、17 类文本 secret 及近似反例,Rust/Python/Web 一致性回归通过;稳定占位符、策略版本、SHA-256 防陈旧和只新建文件已实现</td><td>独立安全评审与 corpus 持续维护</td></tr>
<tr><td class="task-id">TM-09</td><td>恶意 Prompt 触发 UI 注入</td><td>React 默认转义、Tauri CSP</td><td>Raw 仅纯文本、URL 不自动执行、XSS fixture</td></tr>
Expand Down
Loading