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
476 changes: 228 additions & 248 deletions Cargo.lock

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "claude-guard"
version = "3.12.4"
version = "3.12.5"
edition = "2021"
license = "MIT"
description = "ClaudeGuard:Windows AI 桌面应用网络出口门禁(多目标锚点 + 熔断断网)"
Expand Down Expand Up @@ -38,12 +38,12 @@ plist = "1"
libc = "0.2"
libloading = "0.8"
tempfile = "3"
zip = { version = "2", default-features = false, features = ["deflate"] }
# 0.41+ 修掉 RUSTSEC-2026-0194/0195(重复属性名检查的二次方时间、命名空间声明的
# 三倍堆分配),但 0.42 把 local_name() 的返回从裸字节改成了 &str,
# src/macos/claude_update.rs 的字节比较会直接编译失败;那段代码只有 macOS 才编译。
# 等 macOS CI 先跑绿,再连代码一起改——不要在 Windows 上盲改没人验证过的路径。
quick-xml = { version = "0.38", features = ["serialize"] }
# RUSTSEC-2026-0367/0369 的修补版本是 9.0.0,2.x 没有修补版;本项目只用 zip 读条目
# 元数据(解压走系统 ditto),升级面因此只在元数据 API 上。
zip = { version = "9", default-features = false, features = ["deflate"] }
# RUSTSEC-2026-0194/0195 修补于 0.41;这里取 0.42 是为了和 plist 的解析版本对齐,
# 代价是 local_name() 返回 &str 而非裸字节(src/macos/claude_update.rs 已跟着改)。
quick-xml = { version = "0.42", features = ["serialize"] }
url = "2"
minisign-verify = "0.3"

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ macOS:`cargo build --release && python3 scripts/package-macos.py`(详见上
- **v3.11.0**:主界面按「主卡 + 分组卡」重构,CTA 移出滚动区,熔断与暂停改用标题与主钮表达。
- **v3.12.0**:新增直连泄露观察(系统代理模式下发现绕过代理的外连即告警)、环境一致性检查,以及时区/默认位置的自动对齐与一键还原(全部走账本,可随时恢复)。
- **v3.12.4**:修复防火墙自愈在非英文系统上从不触发(改按 `Get-NetFirewallProfile` 的结构化状态判断,不再解析本地化的 netsh 文本);修正 netsh「找不到规则」提示的匹配串;TLS 栈补丁升级(rustls / rustls-webpki / webpki-roots / tokio);CI 接入单元测试并新增 macOS job;补 MIT 许可。
- **v3.12.5**:依赖安全对齐——`zip` 升到 9.x(RUSTSEC-2026-0367/0369 的修补版本线,2.x 无修补版)、`quick-xml` 升到 0.42(RUSTSEC-2026-0194/0195 修补于 0.41,同时消掉锁文件里的重复版本);补齐 macOS 移植缺的符号,macOS job 首次真正编译通过;放宽一个依赖 PowerShell 冷启动的用例时限,消除 CI 偶发失败。

---

Expand Down
2 changes: 1 addition & 1 deletion src/claude_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,7 +1096,7 @@ mod tests {
"[Console]::Write('inspect-ok')\n[Console]::Error.Write('inspect-error')\nexit 7",
])
.creation_flags(0x08000000);
let result = inspect_output(&mut cmd, Duration::from_secs(5)).unwrap();
let result = inspect_output(&mut cmd, Duration::from_secs(60)).unwrap();
assert_eq!(result.status.code(), Some(7));
assert_eq!(result.stdout, b"inspect-ok");
assert_eq!(result.stderr, b"inspect-error");
Expand Down
29 changes: 15 additions & 14 deletions src/macos/claude_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,52 +251,53 @@ fn release_from_codex(
let mut deltas = false;
loop {
match reader.read_event().map_err(|_| "Codex 更新清单 XML 无效")? {
Event::Start(e) if e.local_name().as_ref() == b"item" => {
Event::Start(e) if e.local_name().as_ref() == "item" => {
current = Some(FeedItem::default())
}
Event::Start(e) if e.local_name().as_ref() == b"deltas" => deltas = true,
Event::End(e) if e.local_name().as_ref() == b"deltas" => deltas = false,
Event::Start(e) if e.local_name().as_ref() == "deltas" => deltas = true,
Event::End(e) if e.local_name().as_ref() == "deltas" => deltas = false,
Event::Start(e) if current.is_some() => {
let field = e.local_name();
let name = field.as_ref();
if [
b"shortVersionString".as_slice(),
b"hardwareRequirements",
b"minimumSystemVersion",
"shortVersionString",
"hardwareRequirements",
"minimumSystemVersion",
]
.contains(&name)
{
let text = reader
.read_text(e.name())
.map_err(|_| "Codex 更新字段无效")?
.xml10_content()
.into_owned();
let item = current.as_mut().unwrap();
match name {
b"shortVersionString" => item.version = text,
b"hardwareRequirements" => item.architecture = text,
b"minimumSystemVersion" => item.minimum = text,
"shortVersionString" => item.version = text,
"hardwareRequirements" => item.architecture = text,
"minimumSystemVersion" => item.minimum = text,
_ => {}
}
}
}
Event::Empty(e)
if e.local_name().as_ref() == b"enclosure" && current.is_some() && !deltas =>
if e.local_name().as_ref() == "enclosure" && current.is_some() && !deltas =>
{
let item = current.as_mut().unwrap();
for attribute in e.attributes() {
let a = attribute.map_err(|_| "Codex 更新属性无效")?;
let value = a
.decode_and_unescape_value(reader.decoder())
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map_err(|_| "Codex 更新属性编码无效")?
.into_owned();
match a.key.local_name().as_ref() {
b"url" => item.url = value,
b"length" => item.size = value.parse().map_err(|_| "Codex 包大小无效")?,
"url" => item.url = value,
"length" => item.size = value.parse().map_err(|_| "Codex 包大小无效")?,
_ => {}
}
}
}
Event::End(e) if e.local_name().as_ref() == b"item" => {
Event::End(e) if e.local_name().as_ref() == "item" => {
if let Some(item) = current.take() {
items.push(item);
}
Expand Down
36 changes: 36 additions & 0 deletions src/macos/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,33 @@ pub fn pick_manual(procs: &[ProcInfo], claude_dir_l: Option<&str>) -> Vec<(usize
.collect()
}

/// Claude 进程的存在情况(一次全进程扫描得出)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaudePresence {
/// 没有名为 claude 的进程。
None,
/// 只有 Claude Code CLI。
CliOnly,
/// 桌面端在跑。
Desktop,
}

/// 一次扫描判断 claude 进程的存在情况:有没有、是不是桌面端。
///
/// 与 Windows 侧同名函数语义一致,供共享的 monitor 退出清理每拍只问一次。
pub fn claude_presence(claude_dir_l: Option<&str>) -> ClaudePresence {
let procs = snapshot_processes();
let mut seen = ClaudePresence::None;
for p in &procs {
match classify_claude(p, claude_dir_l) {
Some(ClaudeKind::Desktop) => return ClaudePresence::Desktop,
Some(ClaudeKind::Cli) => seen = ClaudePresence::CliOnly,
None => {}
}
}
seen
}

/// 残留判定(纯函数):桌面端全部消失后,哪些 Claude 进程算"残留"。
/// - 桌面家族进程:主进程不在了,剩下的 crashpad/gpu/renderer 全算残留;
/// - CLI:父进程已死(孤儿)才算残留——挂在活终端下的会话不清,避免误杀正在用的终端。
Expand Down Expand Up @@ -427,6 +454,15 @@ pub fn firewall_dedupe() -> usize {
0
}

/// 本产品防火墙规则总条数。
///
/// 这个构建不创建任何防火墙规则(见 [`firewall_release`] 与 [`firewall_dedupe`]),
/// 恒为 0 条。返回 `Some(0)` 而非 `None`:恢复核验把 `None` 当作"查不到即不干净",
/// 而这里的 0 是确知的,不该被降级成未核验。
pub fn firewall_rule_count() -> Option<usize> {
Some(0)
}

// v3.10 R07:共享代码(state/main/monitor)会调用防火墙兜底与 lockdown 接口。
// macOS 既不支持应用级防火墙隔离,也不支持兜底封锁,因此这里显式返回"未执行",
// 让共享代码可编译,并由上层按 `networkIsolationSupported=false` 在 UI/--check 明示。
Expand Down
5 changes: 5 additions & 0 deletions src/macos/netcut.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
//! No pretend firewall success. Full macOS isolation requires a signed Network Extension.
use crate::model::CutMode;

/// 断网防火墙规则名。这个构建不创建任何规则,但共享代码(账本)要用同一个常量
/// 记账,因此与 Windows 侧保持同名同值。
pub const CUT_RULE: &str = "ClaudeGuard Cut All";

pub fn cut(mode: CutMode) -> (bool, String) {
if mode == CutMode::Off {
(true, "未请求断网".into())
Expand Down
11 changes: 7 additions & 4 deletions src/macos/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,8 @@ fn relative_inside(path: &Path) -> bool {
/// 这里刻意不走 `ZipArchive::extract()` / `ZipStreamReader::extract()`——那两个 API
/// 正是 2026 年三条 zip 公告(RUSTSEC-2026-0367 / -0368 / -0369)的攻击面,而本项目的
/// 解压走系统 `/usr/bin/ditto -x -k`(见 [`extract`]),zip 只用来读条目元数据。
/// 同理 zip 停在 2.x:三条公告的修复版本都是 9.0.0,不存在 2.x 修补版,而升大版本要
/// 重验这段只有 macOS 才编译的代码,收益为零。一旦改成调用 `extract()`,
/// 就必须同时升到已修补版本并重新评估这三条公告。
/// 依赖已随公告对齐到 9.x(修补版本线),但「不调用 extract()」这条仍然成立:
/// 一旦改成调用 `extract()`,解压侧的路径与权限校验就得整段重做。
pub fn validate_archive(path: &Path) -> Result<(), String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut archive = zip::ZipArchive::new(file).map_err(|_| "不是有效的 ZIP 更新包")?;
Expand All @@ -190,7 +189,11 @@ pub fn validate_archive(path: &Path) -> Result<(), String> {
for index in 0..archive.len() {
let mut item = archive.by_index(index).map_err(|_| "无法读取更新包目录")?;
let enclosed = item.enclosed_name().ok_or("更新包包含越界路径")?;
if !relative_inside(&enclosed) || item.name().contains('\\') {
let has_backslash = item
.name()
.map_err(|_| "无法读取更新包目录")?
.contains('\\');
if !relative_inside(&enclosed) || has_backslash {
return Err("更新包路径不安全".into());
}
// Normalise before duplicate/ancestor checks; extraction tools may follow
Expand Down
4 changes: 4 additions & 0 deletions src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use crate::state::AppState;
use serde::{Deserialize, Serialize};
#[cfg(windows)]
use std::io::{Read, Write};
#[cfg(target_os = "macos")]
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
Expand Down Expand Up @@ -877,6 +879,8 @@ mod tests {
/// 手动跑的真实下载验证(默认 ignore,门禁不联网):
/// `cargo test --offline download_real_asset -- --ignored --nocapture`
/// 依赖本机代理可用;验证的是"GitHub 302 能被正确跟随"这条链路。
/// 只针对 Windows 发布资产与 `Staged` 句柄模型,macOS 自更新走归档,不适用。
#[cfg(windows)]
#[test]
#[ignore = "需要代理与网络,手动执行"]
fn download_real_asset_follows_github_redirect() {
Expand Down
Loading