From 146eb81b941cf9e93af28a5f1971bb00d0abe3fb Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 20 Jul 2026 22:26:01 +0800 Subject: [PATCH 1/2] feat: integrate macOS privileged proxy helper --- Cargo.lock | 1 + apps/desktop/src-tauri/src/lib.rs | 61 ++- apps/desktop/src-tauri/src/main.rs | 219 ++++++++++- crates/proxy-recovery/Cargo.toml | 3 + crates/proxy-recovery/README.md | 2 +- crates/proxy-recovery/src/lib.rs | 4 +- crates/proxy-recovery/src/macos_privileged.rs | 372 +++++++++++++++++- docs/progress.html | 17 +- docs/security.html | 2 +- 9 files changed, 643 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18abdb3..39f3b10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "libc", "serde", "serde_json", + "tempfile", "url", "windows-sys 0.61.2", "winreg", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index f158b60..7f4742f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -31,7 +31,12 @@ use codeischeap_desktop_api::{ }; use codeischeap_gateway::{Gateway, GatewayCapture, GatewayCaptureEvent}; use codeischeap_process_attribution::resolve_loopback_client_pid; +#[cfg(not(target_os = "macos"))] use codeischeap_proxy_recovery::recover_from_journal; +#[cfg(target_os = "macos")] +use codeischeap_proxy_recovery::{ + MacOsPrivilegedProxySession, recover_macos_proxy_journal_with_authorization, +}; #[cfg(windows)] use codeischeap_proxy_recovery::{ProxySession, ProxySettings, WindowsProxyBackend}; use codeischeap_sidecar_runtime::{ @@ -193,6 +198,8 @@ impl Drop for ProxyRuntime { enum PlatformProxySession { #[cfg(windows)] Windows(ProxySession), + #[cfg(target_os = "macos")] + MacOs(MacOsPrivilegedProxySession), } impl PlatformProxySession { @@ -200,6 +207,8 @@ impl PlatformProxySession { match self { #[cfg(windows)] Self::Windows(session) => session.restore().map_err(|error| error.to_string()), + #[cfg(target_os = "macos")] + Self::MacOs(session) => session.restore().map_err(|error| error.to_string()), } } } @@ -1038,7 +1047,7 @@ async fn ensure_proxy_recovery(app: &AppHandle, state: &DesktopState) -> Result< return Ok(()); } let journal = application_proxy_recovery_journal(app)?; - tauri::async_runtime::spawn_blocking(move || recover_from_journal(&journal)) + tauri::async_runtime::spawn_blocking(move || recover_platform_proxy_journal(&journal)) .await .map_err(|error| format!("proxy recovery task failed: {error}"))? .map_err(|error| error.to_string())?; @@ -1046,6 +1055,20 @@ async fn ensure_proxy_recovery(app: &AppHandle, state: &DesktopState) -> Result< Ok(()) } +#[cfg(not(target_os = "macos"))] +fn recover_platform_proxy_journal( + journal: &Path, +) -> Result { + recover_from_journal(journal) +} + +#[cfg(target_os = "macos")] +fn recover_platform_proxy_journal( + journal: &Path, +) -> Result { + recover_macos_proxy_journal_with_authorization(std::env::current_exe()?, journal) +} + async fn ensure_gateway(app: &AppHandle, state: &DesktopState) -> Result<(), String> { let mode = *state.mode.lock().await; let capture_enabled = @@ -1294,7 +1317,7 @@ async fn take_failed_proxy_runtime(state: &DesktopState, generation: u64) -> Opt runtime } -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] async fn activate_system_proxy(app: &AppHandle, runtime: &mut ProxyRuntime) -> Result<(), String> { if runtime.system_proxy.is_some() { return Ok(()); @@ -1305,12 +1328,12 @@ async fn activate_system_proxy(app: &AppHandle, runtime: &mut ProxyRuntime) -> R { return Ok(()); } - let desired = system_proxy_settings(&runtime.endpoint); + let endpoint = runtime.endpoint.clone(); let journal = application_proxy_recovery_journal(app)?; - let watchdog = std::env::current_exe() - .map_err(|error| format!("proxy watchdog executable is unavailable: {error}"))?; + let executable = std::env::current_exe() + .map_err(|error| format!("proxy helper executable is unavailable: {error}"))?; let session = tauri::async_runtime::spawn_blocking(move || { - begin_platform_proxy_session(desired, journal, watchdog) + begin_platform_proxy_session(endpoint, journal, executable) }) .await .map_err(|error| format!("system proxy task failed: {error}"))??; @@ -1318,7 +1341,7 @@ async fn activate_system_proxy(app: &AppHandle, runtime: &mut ProxyRuntime) -> R Ok(()) } -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "macos")))] async fn activate_system_proxy( _app: &AppHandle, _runtime: &mut ProxyRuntime, @@ -1347,12 +1370,28 @@ fn system_proxy_bypass() -> Vec { #[cfg(windows)] fn begin_platform_proxy_session( - desired: ProxySettings, + endpoint: String, + journal: PathBuf, + executable: PathBuf, +) -> Result { + ProxySession::begin( + WindowsProxyBackend::system(), + system_proxy_settings(&endpoint), + journal, + executable, + ) + .map(PlatformProxySession::Windows) + .map_err(|error| error.to_string()) +} + +#[cfg(target_os = "macos")] +fn begin_platform_proxy_session( + endpoint: String, journal: PathBuf, - watchdog: PathBuf, + executable: PathBuf, ) -> Result { - ProxySession::begin(WindowsProxyBackend::system(), desired, journal, watchdog) - .map(PlatformProxySession::Windows) + MacOsPrivilegedProxySession::begin(executable, journal, &endpoint, Duration::from_secs(15)) + .map(PlatformProxySession::MacOs) .map_err(|error| error.to_string()) } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 5af3271..b09bcf3 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,20 +1,215 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; + +enum StartupCommand { + Application, + Watchdog { + journal: PathBuf, + }, + #[cfg(target_os = "macos")] + MacOsProxyHelperDaemon { + journal: PathBuf, + status: PathBuf, + socket: PathBuf, + endpoint: String, + owner_pid: u32, + owner_uid: u32, + }, + #[cfg(target_os = "macos")] + MacOsProxyHelperRecover { + journal: PathBuf, + owner_uid: u32, + }, +} + fn main() { - let mut arguments = std::env::args_os().skip(1); - if arguments.next().as_deref() == Some(std::ffi::OsStr::new("--journal")) { - let Some(journal) = arguments.next() else { - std::process::exit(2); - }; - if arguments.next().is_some() { + let command = match parse_startup_command(std::env::args_os().skip(1)) { + Ok(command) => command, + Err(error) => { + eprintln!("CodeIsCheap startup arguments are invalid: {error}"); std::process::exit(2); } - if let Err(error) = codeischeap_proxy_recovery::run_watchdog(std::path::Path::new(&journal)) - { - eprintln!("CodeIsCheap proxy recovery failed: {error}"); - std::process::exit(1); + }; + match command { + StartupCommand::Application => codeischeap_desktop_lib::run(), + StartupCommand::Watchdog { journal } => { + if let Err(error) = codeischeap_proxy_recovery::run_watchdog(&journal) { + eprintln!("CodeIsCheap proxy recovery failed: {error}"); + std::process::exit(1); + } + } + #[cfg(target_os = "macos")] + StartupCommand::MacOsProxyHelperDaemon { + journal, + status, + socket, + endpoint, + owner_pid, + owner_uid, + } => { + if let Err(error) = codeischeap_proxy_recovery::run_macos_privileged_proxy_helper( + journal, status, socket, &endpoint, owner_pid, owner_uid, + ) { + eprintln!("CodeIsCheap macOS proxy helper failed: {error}"); + std::process::exit(1); + } + } + #[cfg(target_os = "macos")] + StartupCommand::MacOsProxyHelperRecover { journal, owner_uid } => { + match codeischeap_proxy_recovery::run_macos_privileged_proxy_recovery( + journal, owner_uid, + ) { + Ok(true) => println!("recovered"), + Ok(false) => println!("clean"), + Err(error) => { + eprintln!("CodeIsCheap macOS proxy recovery failed: {error}"); + std::process::exit(1); + } + } + } + } +} + +fn parse_startup_command( + arguments: impl IntoIterator, +) -> Result { + let mut arguments = arguments.into_iter(); + let Some(first) = arguments.next() else { + return Ok(StartupCommand::Application); + }; + match first.as_os_str() { + value if value == OsStr::new("--journal") => { + let journal = required_value(&mut arguments, "--journal")?.into(); + ensure_finished(&mut arguments)?; + Ok(StartupCommand::Watchdog { journal }) + } + #[cfg(target_os = "macos")] + value if value == OsStr::new("--macos-proxy-helper-daemon") => { + let journal = named_value(&mut arguments, "--journal")?.into(); + let status = named_value(&mut arguments, "--status")?.into(); + let socket = named_value(&mut arguments, "--socket")?.into(); + let endpoint = unicode_value(named_value(&mut arguments, "--endpoint")?, "endpoint")?; + let owner_pid = + numeric_value(named_value(&mut arguments, "--owner-pid")?, "owner PID")?; + let owner_uid = + numeric_value(named_value(&mut arguments, "--owner-uid")?, "owner UID")?; + ensure_finished(&mut arguments)?; + Ok(StartupCommand::MacOsProxyHelperDaemon { + journal, + status, + socket, + endpoint, + owner_pid, + owner_uid, + }) + } + #[cfg(target_os = "macos")] + value if value == OsStr::new("--macos-proxy-helper-recover") => { + let journal = named_value(&mut arguments, "--journal")?.into(); + let owner_uid = + numeric_value(named_value(&mut arguments, "--owner-uid")?, "owner UID")?; + ensure_finished(&mut arguments)?; + Ok(StartupCommand::MacOsProxyHelperRecover { journal, owner_uid }) } - return; + _ => Ok(StartupCommand::Application), + } +} + +fn named_value( + arguments: &mut impl Iterator, + expected_name: &str, +) -> Result { + let name = arguments + .next() + .ok_or_else(|| format!("missing {expected_name}"))?; + if name != OsStr::new(expected_name) { + return Err(format!("expected {expected_name}")); + } + required_value(arguments, expected_name) +} + +fn required_value( + arguments: &mut impl Iterator, + name: &str, +) -> Result { + arguments + .next() + .ok_or_else(|| format!("missing value for {name}")) +} + +fn unicode_value(value: OsString, name: &str) -> Result { + value + .into_string() + .map_err(|_| format!("{name} must be valid Unicode")) +} + +fn numeric_value(value: OsString, name: &str) -> Result { + unicode_value(value, name)? + .parse() + .map_err(|_| format!("{name} must be an unsigned 32-bit integer")) +} + +fn ensure_finished(arguments: &mut impl Iterator) -> Result<(), String> { + if arguments.next().is_some() { + return Err("unexpected trailing arguments".to_owned()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn arguments(values: &[&str]) -> impl Iterator + '_ { + values.iter().map(|value| OsString::from(*value)) + } + + #[test] + fn watchdog_arguments_are_strict() { + assert!(matches!( + parse_startup_command(arguments(&["--journal", "recovery.json"])).unwrap(), + StartupCommand::Watchdog { .. } + )); + assert!(parse_startup_command(arguments(&["--journal"])).is_err()); + assert!( + parse_startup_command(arguments(&["--journal", "recovery.json", "extra"])).is_err() + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_helper_arguments_are_strict() { + let daemon = [ + "--macos-proxy-helper-daemon", + "--journal", + "/private/recovery/proxy-recovery.v0.1.json", + "--status", + "/private/recovery/helper.status", + "--socket", + "/private/tmp/helper.sock", + "--endpoint", + "http://127.0.0.1:43125", + "--owner-pid", + "123", + "--owner-uid", + "501", + ]; + assert!(matches!( + parse_startup_command(arguments(&daemon)).unwrap(), + StartupCommand::MacOsProxyHelperDaemon { .. } + )); + assert!(parse_startup_command(arguments(&daemon[..daemon.len() - 1])).is_err()); + assert!( + parse_startup_command(arguments(&[ + "--macos-proxy-helper-recover", + "--journal", + "/private/recovery/proxy-recovery.v0.1.json", + "--owner-uid", + "501", + ])) + .is_ok() + ); } - codeischeap_desktop_lib::run(); } diff --git a/crates/proxy-recovery/Cargo.toml b/crates/proxy-recovery/Cargo.toml index cd0f319..0c0e200 100644 --- a/crates/proxy-recovery/Cargo.toml +++ b/crates/proxy-recovery/Cargo.toml @@ -16,3 +16,6 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Threading"] } winreg.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/proxy-recovery/README.md b/crates/proxy-recovery/README.md index c2a2924..69f2ab0 100644 --- a/crates/proxy-recovery/README.md +++ b/crates/proxy-recovery/README.md @@ -12,4 +12,4 @@ This crate owns the transaction state machine used before CodeIsCheap changes sy The included file backend exists only for deterministic crash injection. Windows WinINet and macOS networksetup backends have both passed real force-kill recovery experiments on temporary GitHub runners. -The macOS privileged helper protocol is versioned and deliberately narrow: it accepts only an explicit loopback proxy endpoint, requires a private user-owned recovery directory, exposes a mode-0600 Unix socket, binds the single control connection to the requesting UID and PID, and delegates crash recovery to a second root watchdog. Authorization launch and desktop lifecycle wiring remain separate integration work. +The macOS privileged helper protocol is versioned and deliberately narrow: it accepts only an explicit loopback proxy endpoint, requires a private user-owned recovery directory, exposes a mode-0600 Unix socket, binds the single control connection to the requesting UID and PID, and delegates crash recovery to a second root watchdog. The desktop starts the same validated executable through a static, shell-quoted AppleScript authorization command and keeps a control connection open for the proxy lifetime. Armed journals are restored on the next startup through a separate authorized recovery command. diff --git a/crates/proxy-recovery/src/lib.rs b/crates/proxy-recovery/src/lib.rs index 9c7bbd3..225d605 100644 --- a/crates/proxy-recovery/src/lib.rs +++ b/crates/proxy-recovery/src/lib.rs @@ -31,7 +31,9 @@ pub use windows::WindowsProxyBackend; pub use macos::MacOsProxyBackend; #[cfg(target_os = "macos")] pub use macos_privileged::{ - MacOsPrivilegedProxySession, run_macos_privileged_proxy_helper, run_macos_proxy_helper_session, + MacOsPrivilegedProxySession, recover_macos_proxy_journal_with_authorization, + run_macos_privileged_proxy_helper, run_macos_privileged_proxy_recovery, + run_macos_proxy_helper_session, }; pub const RECOVERY_JOURNAL_VERSION: &str = "0.1"; diff --git a/crates/proxy-recovery/src/macos_privileged.rs b/crates/proxy-recovery/src/macos_privileged.rs index 121fadb..aa6099c 100644 --- a/crates/proxy-recovery/src/macos_privileged.rs +++ b/crates/proxy-recovery/src/macos_privileged.rs @@ -8,8 +8,10 @@ use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _, PermissionsExt as _} use std::os::unix::io::AsRawFd as _; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -18,7 +20,7 @@ use url::{Host, Url}; use crate::{ MACOS_PRIVILEGED_HELPER_PROTOCOL_VERSION, MACOS_PROXY_RECOVERY_JOURNAL_FILENAME, MacOsProxyBackend, ProxyBackend, ProxySession, ProxySettings, RecoveryError, - owner_process_is_running, write_json_atomic, + owner_process_is_running, recover_from_journal, write_json_atomic, }; const MAX_CONTROL_FRAME_BYTES: usize = 1024; @@ -26,6 +28,24 @@ const HELPER_STATUS_PREFIX: &str = ".codeischeap-proxy-helper-"; const HELPER_STATUS_SUFFIX: &str = ".status"; const HELPER_SOCKET_PREFIX: &str = "codeischeap-proxy-helper-"; const HELPER_SOCKET_SUFFIX: &str = ".sock"; +const HELPER_SOCKET_DIRECTORY: &str = "/tmp"; +const OSASCRIPT: &str = "/usr/bin/osascript"; +const HELPER_START_SCRIPT: &str = r#" +on run argv + if (count of argv) is not 7 then error "invalid CodeIsCheap helper arguments" + set commandText to "/usr/bin/nohup " & quoted form of item 1 of argv & " --macos-proxy-helper-daemon --journal " & quoted form of item 2 of argv & " --status " & quoted form of item 3 of argv & " --socket " & quoted form of item 4 of argv & " --endpoint " & quoted form of item 5 of argv & " --owner-pid " & quoted form of item 6 of argv & " --owner-uid " & quoted form of item 7 of argv & " >/dev/null 2>&1 &" + do shell script commandText with administrator privileges +end run +"#; +const HELPER_RECOVER_SCRIPT: &str = r#" +on run argv + if (count of argv) is not 3 then error "invalid CodeIsCheap recovery arguments" + set commandText to quoted form of item 1 of argv & " --macos-proxy-helper-recover --journal " & quoted form of item 2 of argv & " --owner-uid " & quoted form of item 3 of argv + do shell script commandText with administrator privileges +end run +"#; + +static HELPER_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Serialize, Deserialize)] struct HelperStatus { @@ -75,16 +95,66 @@ pub struct MacOsPrivilegedProxySession { } impl MacOsPrivilegedProxySession { + pub fn begin( + executable: impl AsRef, + journal_path: impl AsRef, + endpoint: &str, + timeout: Duration, + ) -> Result { + helper_proxy_settings(endpoint)?; + let executable = validate_helper_executable(executable.as_ref())?; + let journal_path = journal_path.as_ref(); + let owner_uid = current_user_uid()?; + let artifacts = prepare_helper_artifacts(journal_path, owner_uid)?; + launch_privileged_helper( + &executable, + journal_path, + &artifacts.status_path, + &artifacts.socket_path, + endpoint, + std::process::id(), + owner_uid, + )?; + let session = Self::connect_with_identities( + &artifacts.status_path, + &artifacts.socket_path, + owner_uid, + 0, + timeout, + ); + if session.is_err() { + let _ = fs::remove_file(&artifacts.status_path); + let _ = fs::remove_file(&artifacts.socket_path); + } + session + } + pub fn connect( status_path: impl AsRef, socket_path: impl AsRef, expected_helper_uid: u32, timeout: Duration, + ) -> Result { + Self::connect_with_identities( + status_path, + socket_path, + expected_helper_uid, + expected_helper_uid, + timeout, + ) + } + + fn connect_with_identities( + status_path: impl AsRef, + socket_path: impl AsRef, + expected_socket_uid: u32, + expected_helper_uid: u32, + timeout: Duration, ) -> Result { let status_path = status_path.as_ref(); let socket_path = socket_path.as_ref(); wait_for_ready_status(status_path, timeout)?; - validate_socket_owner(socket_path, expected_helper_uid)?; + validate_socket_owner(socket_path, expected_socket_uid)?; let deadline = Instant::now() + timeout; let mut stream = loop { @@ -151,6 +221,49 @@ impl Drop for MacOsPrivilegedProxySession { } } +pub fn recover_macos_proxy_journal_with_authorization( + executable: impl AsRef, + journal_path: impl AsRef, +) -> Result { + let journal_path = journal_path.as_ref(); + if !journal_path.exists() { + return Ok(false); + } + let owner_uid = current_user_uid()?; + validate_user_recovery_path(journal_path, owner_uid)?; + let executable = validate_helper_executable(executable.as_ref())?; + let output = Command::new(OSASCRIPT) + .arg("-e") + .arg(HELPER_RECOVER_SCRIPT) + .arg(executable) + .arg(journal_path) + .arg(owner_uid.to_string()) + .output()?; + ensure_authorization_succeeded(&output)?; + match String::from_utf8_lossy(&output.stdout).trim() { + "recovered" => Ok(true), + "clean" => Ok(false), + _ => Err(RecoveryError::PrivilegedHelper( + "authorized recovery returned an unexpected response".to_owned(), + )), + } +} + +pub fn run_macos_privileged_proxy_recovery( + journal_path: impl AsRef, + owner_uid: u32, +) -> Result { + if unsafe { libc::geteuid() } != 0 { + return Err(RecoveryError::PrivilegedHelper( + "recovery helper must run as root".to_owned(), + )); + } + let journal_path = journal_path.as_ref(); + validate_user_recovery_path(journal_path, owner_uid)?; + validate_recovery_journal(journal_path, 0)?; + recover_from_journal(journal_path) +} + pub fn run_macos_privileged_proxy_helper( journal_path: impl AsRef, status_path: impl AsRef, @@ -296,7 +409,7 @@ fn validate_privileged_configuration( let socket_parent = socket_path .parent() .ok_or_else(|| RecoveryError::PrivilegedHelper("socket path has no parent".to_owned()))?; - if fs::canonicalize(socket_parent)? != fs::canonicalize(env::temp_dir())? { + if fs::canonicalize(socket_parent)? != fs::canonicalize(HELPER_SOCKET_DIRECTORY)? { return Err(RecoveryError::PrivilegedHelper( "helper socket must use the system temporary directory".to_owned(), )); @@ -310,6 +423,161 @@ fn validate_privileged_configuration( Ok(()) } +fn launch_privileged_helper( + executable: &Path, + journal_path: &Path, + status_path: &Path, + socket_path: &Path, + endpoint: &str, + owner_pid: u32, + owner_uid: u32, +) -> Result<(), RecoveryError> { + let output = Command::new(OSASCRIPT) + .arg("-e") + .arg(HELPER_START_SCRIPT) + .arg(executable) + .arg(journal_path) + .arg(status_path) + .arg(socket_path) + .arg(endpoint) + .arg(owner_pid.to_string()) + .arg(owner_uid.to_string()) + .output()?; + ensure_authorization_succeeded(&output) +} + +fn ensure_authorization_succeeded(output: &std::process::Output) -> Result<(), RecoveryError> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.contains("User canceled") || stderr.contains("(-128)") { + "the user cancelled the macOS administrator prompt".to_owned() + } else { + let trimmed = stderr.trim(); + if trimmed.is_empty() { + "macOS administrator authorization failed".to_owned() + } else { + format!("macOS administrator authorization failed: {trimmed}") + } + }; + Err(RecoveryError::PrivilegedHelper(detail)) +} + +fn current_user_uid() -> Result { + let uid = unsafe { libc::geteuid() }; + if uid == 0 { + return Err(RecoveryError::PrivilegedHelper( + "desktop helper authorization cannot start from a root session".to_owned(), + )); + } + Ok(uid) +} + +fn validate_helper_executable(path: &Path) -> Result { + let canonical = fs::canonicalize(path)?; + let metadata = fs::metadata(&canonical)?; + if !metadata.file_type().is_file() || metadata.mode() & 0o022 != 0 { + return Err(RecoveryError::PrivilegedHelper( + "helper executable must be a regular file that is not group- or world-writable" + .to_owned(), + )); + } + Ok(canonical) +} + +fn prepare_helper_artifacts( + journal_path: &Path, + owner_uid: u32, +) -> Result { + ensure_user_recovery_directory(journal_path)?; + validate_user_recovery_path(journal_path, owner_uid)?; + if journal_path.exists() { + return Err(RecoveryError::PrivilegedHelper( + "an armed recovery journal exists and must be restored first".to_owned(), + )); + } + let nonce = helper_launch_nonce(); + let recovery_directory = journal_path.parent().ok_or_else(|| { + RecoveryError::PrivilegedHelper("recovery journal has no parent".to_owned()) + })?; + let status_path = recovery_directory.join(format!( + "{HELPER_STATUS_PREFIX}{nonce}{HELPER_STATUS_SUFFIX}" + )); + let socket_path = Path::new(HELPER_SOCKET_DIRECTORY).join(format!( + "{HELPER_SOCKET_PREFIX}{nonce}{HELPER_SOCKET_SUFFIX}" + )); + if status_path.exists() || socket_path.exists() { + return Err(RecoveryError::PrivilegedHelper( + "new helper artifacts unexpectedly already exist".to_owned(), + )); + } + Ok(HelperArtifacts { + status_path, + socket_path, + }) +} + +fn validate_user_recovery_path(path: &Path, owner_uid: u32) -> Result<(), RecoveryError> { + if !path.is_absolute() + || path.file_name().and_then(|name| name.to_str()) + != Some(MACOS_PROXY_RECOVERY_JOURNAL_FILENAME) + { + return Err(RecoveryError::PrivilegedHelper( + "recovery journal path is not allowed".to_owned(), + )); + } + let parent = path + .parent() + .ok_or_else(|| RecoveryError::PrivilegedHelper("recovery path has no parent".to_owned()))?; + let canonical = fs::canonicalize(parent)?; + if canonical != parent { + return Err(RecoveryError::PrivilegedHelper( + "recovery directory cannot contain symbolic links".to_owned(), + )); + } + let metadata = fs::metadata(parent)?; + if metadata.uid() != owner_uid || metadata.mode() & 0o077 != 0 { + return Err(RecoveryError::PrivilegedHelper( + "recovery directory must be owned by the requesting user and mode 0700".to_owned(), + )); + } + Ok(()) +} + +fn ensure_user_recovery_directory(path: &Path) -> Result<(), RecoveryError> { + let parent = path + .parent() + .ok_or_else(|| RecoveryError::PrivilegedHelper("recovery path has no parent".to_owned()))?; + if !parent.exists() { + fs::create_dir_all(parent)?; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +fn validate_recovery_journal(path: &Path, expected_uid: u32) -> Result<(), RecoveryError> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() + || metadata.uid() != expected_uid + || metadata.mode() & 0o077 != 0 + { + return Err(RecoveryError::PrivilegedHelper( + "recovery journal ownership or permissions are invalid".to_owned(), + )); + } + Ok(()) +} + +fn helper_launch_nonce() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = HELPER_SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!("{}-{nanos}-{sequence}", std::process::id()) +} + fn private_owner_directory(path: &Path, owner_uid: u32) -> Result { if !path.is_absolute() { return Err(RecoveryError::PrivilegedHelper( @@ -601,6 +869,11 @@ struct HelperArtifactCleanup { socket_path: PathBuf, } +struct HelperArtifacts { + status_path: PathBuf, + socket_path: PathBuf, +} + impl HelperArtifactCleanup { fn new(status_path: &Path, socket_path: &Path) -> Self { Self { @@ -620,6 +893,7 @@ impl Drop for HelperArtifactCleanup { #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; #[test] fn privileged_endpoint_accepts_only_explicit_loopback_http() { @@ -661,4 +935,94 @@ mod tests { ); } } + + #[test] + fn helper_artifacts_create_a_private_directory_with_matching_nonces() { + let directory = tempdir().expect("temporary directory must be created"); + let journal = fs::canonicalize(directory.path()) + .unwrap() + .join("recovery") + .join(MACOS_PROXY_RECOVERY_JOURNAL_FILENAME); + let artifacts = prepare_helper_artifacts(&journal, unsafe { libc::geteuid() }) + .expect("helper artifacts must be prepared"); + + let recovery = journal.parent().expect("journal must have a parent"); + assert_eq!(fs::metadata(recovery).unwrap().mode() & 0o777, 0o700); + assert_eq!( + helper_nonce( + &artifacts.status_path, + HELPER_STATUS_PREFIX, + HELPER_STATUS_SUFFIX + ) + .unwrap(), + helper_nonce( + &artifacts.socket_path, + HELPER_SOCKET_PREFIX, + HELPER_SOCKET_SUFFIX + ) + .unwrap() + ); + assert!(!artifacts.status_path.exists()); + assert!(!artifacts.socket_path.exists()); + } + + #[test] + fn helper_executable_rejects_group_or_world_writable_files() { + let directory = tempdir().expect("temporary directory must be created"); + let executable = directory.path().join("CodeIsCheap"); + fs::write(&executable, b"test executable").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(validate_helper_executable(&executable).is_ok()); + + fs::set_permissions(&executable, fs::Permissions::from_mode(0o775)).unwrap(); + assert!(validate_helper_executable(&executable).is_err()); + } + + #[test] + fn authorization_scripts_quote_every_dynamic_argument() { + for index in 1..=7 { + assert!( + HELPER_START_SCRIPT.contains(&format!("quoted form of item {index} of argv")), + "start argument {index} must be shell quoted" + ); + } + for index in 1..=3 { + assert!( + HELPER_RECOVER_SCRIPT.contains(&format!("quoted form of item {index} of argv")), + "recovery argument {index} must be shell quoted" + ); + } + } + + #[test] + fn recovery_validation_requires_an_existing_private_owner_directory() { + let directory = tempdir().expect("temporary directory must be created"); + let recovery = fs::canonicalize(directory.path()).unwrap().join("recovery"); + let journal = recovery.join(MACOS_PROXY_RECOVERY_JOURNAL_FILENAME); + let uid = unsafe { libc::geteuid() }; + + assert!(validate_user_recovery_path(&journal, uid).is_err()); + fs::create_dir(&recovery).unwrap(); + fs::set_permissions(&recovery, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(validate_user_recovery_path(&journal, uid).is_ok()); + fs::set_permissions(&recovery, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(validate_user_recovery_path(&journal, uid).is_err()); + } + + #[test] + fn recovery_journal_validation_rejects_unsafe_files() { + let directory = tempdir().expect("temporary directory must be created"); + let journal = directory.path().join(MACOS_PROXY_RECOVERY_JOURNAL_FILENAME); + fs::write(&journal, b"{}").unwrap(); + fs::set_permissions(&journal, fs::Permissions::from_mode(0o600)).unwrap(); + let uid = unsafe { libc::geteuid() }; + assert!(validate_recovery_journal(&journal, uid).is_ok()); + + fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(validate_recovery_journal(&journal, uid).is_err()); + + let link = directory.path().join("journal-link"); + std::os::unix::fs::symlink(&journal, &link).unwrap(); + assert!(validate_recovery_journal(&link, uid).is_err()); + } } diff --git a/docs/progress.html b/docs/progress.html index b352a19..d94ed39 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -16,13 +16,13 @@

开发进度跟踪

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

-
工程进度78%
+
工程进度79%
当前阶段恢复、安全与协议扩展
当前迭代S5 · 进行中 / S6 · 完成
-
最后更新2026-07-18
+
最后更新2026-07-19
-
当前结论四厂商 Prompt IR、搜索、Compare 与安全导出已闭环;共享 secret corpus 已覆盖 Capture、sidecar、导出和浏览器 fallback。下一步推进日志/临时文件 canary 与 macOS helper。
+
当前结论四厂商 Prompt IR、搜索、Compare 与安全导出已闭环;macOS 特权代理 helper 已接入授权启动、桌面生命周期和启动恢复。下一步完成真实管理员交互与强杀矩阵,并推进 OS socket ACL。

1. 更新规则

    @@ -48,7 +48,7 @@

    3. 里程碑

    M0Discovery 与技术验证5%
    95%In progressW2TBD M1基础设施与加密数据链路15%
    95%In progressW4Codex / TBD M2Gateway 与双厂商闭环20%
    100%DoneW8Codex / TBD - M3显式代理、安全与恢复20%
    70%In progressW12Codex / TBD + M3显式代理、安全与恢复20%
    75%In progressW12Codex / TBD M4四厂商、Compare 与搜索15%
    100%DoneW14Codex / TBD M5Public Beta 稳定性15%
    0%Not startedW18TBD M6GA 发布10%
    0%Not startedW20TBD @@ -59,7 +59,7 @@

    4. 工作流进度

    - + @@ -83,7 +83,7 @@

    5. 当前迭代:S5 / S6

    - + @@ -99,7 +99,7 @@

    5. 当前迭代:S5 / S6

    - + @@ -123,7 +123,7 @@

    6. 后续迭代承诺

    - + @@ -224,6 +224,7 @@

    11. 决策与变更记录

    +
    工作流任务范围进度状态Owner本周重点
    架构与工程基础ARC-001~004100%DoneCodex / TBD保持 schema、事件与 command 契约同步
    Desktop / FrontendAPP-001~00694%In progressCodex / TBD采集首次捕获耗时并完成真实运行验收
    Capture / NetworkCAP-001~00785%In progressCodex / TBD补齐 macOS 特权代理 helper;生产签名随发布凭据补齐
    Capture / NetworkCAP-001~00788%In progressCodex / TBD完成 macOS helper 真实故障矩阵;生产签名随发布凭据补齐
    Prompt / AdaptersPAR-001~007100%DoneCodex / TBD保持价格目录与 provider usage 映射可追溯
    Data / SecurityDAT-001~002、SEC-001~00475%In progressCodex / TBD继续 OS 级 IPC ACL、WASI 权限、供应链控制与独立安全评审
    Test / ReleaseTST-001~004、REL-001~00355%In progressCodex / TBD继续 TST-002 完整协议一致性与支持处理流程
    CAP-003打包 mitmproxy sidecar 与最小 addonIn progress96%Codex / Release Owner TBD2026-08-25三平台 bundle、manifest、SBOM、原子安装、Tauri 资源、运行时校验、最小环境、独立 readiness token 与 Windows Job Object/Unix 进程组清理完成;正式 Authenticode/Developer ID 制品待发布凭据
    CAP-004显式代理与 TLS flowDone100%Codex / TBD2026-07-15桌面 sidecar、认证 IPC、模式与暂停控制、进程树清理、非目标 TLS 直通、gzip/brotli、SSE/NDJSON/JSON-seq 脱敏完成;真实 TLS+ALPN HTTP/2 客户端/服务端证明代理请求响应与直连基线一致
    CAP-005证书生命周期In progress85%Codex / TBD2026-08-25跨平台 CA 元数据、私钥权限与系统信任可核验;Windows CurrentUser ROOT 与 macOS User domain 均支持严格校验后的幂等安装/卸载,卸载前自动退出 Proxy;Admin/System 只读,真实交互验收待完成
    CAP-006系统代理快照与 watchdogIn progress85%Codex / TBD2026-08-25Windows 桌面 Proxy 模式已接入 CurrentUser WinINet 事务、私有 journal、同一签名可执行文件 watchdog、活 owner 防误恢复和启动修复;sidecar 独立退出会按运行时代际安全回退 Gateway 并恢复代理;macOS helper 核心已限制为 loopback 端点、私有目录、0600 Unix socket、UID/PID 唯一 owner 与 root watchdog,授权启动和桌面接入待完成
    CAP-006系统代理快照与 watchdogIn progress92%Codex / TBD2026-08-25Windows 桌面 Proxy 模式已接入 CurrentUser WinINet 事务、私有 journal、同一签名可执行文件 watchdog、活 owner 防误恢复和启动修复;sidecar 独立退出会按运行时代际安全回退 Gateway 并恢复代理;macOS 以静态逐参数 quote 的授权命令启动同一受校验可执行文件,持久 UID/PID 绑定控制连接覆盖正常退出与 owner 断连,root watchdog 覆盖 helper 崩溃,armed journal 在下次启动经单次授权恢复;真实管理员交互与强杀矩阵待验收
    CAP-007进程/应用归因与兼容诊断Done100%Codex / TBD2026-07-18应用标签/User-Agent/捕获模式归因及兼容诊断树完成;Windows/macOS/Linux Gateway 与 Proxy 均通过操作系统 TCP 连接精确匹配 PID,失败不推断,sidecar PID 不受信且临时端点不进入 Envelope、持久化或导出
    DAT-001SQLCipher、迁移、WAL 与 OS 凭据库Done100%Codex / TBD2026-07-15schema v2 可从 v0/v1 升级,响应类型、状态码和耗时可查询;备份恢复、DB/WAL canary 与 OS 凭据库均通过
    DAT-002写入、查询、保留与磁盘压力Done100%Codex / TBD2026-07-15批量写入原子提交;默认保留 30 天/50,000 条并以 500 条事务清理;低于 256 MiB 或 SQLITE_FULL 时暂停记录但保持 Gateway 转发
    PAR-007token、成本和语义指纹Done100%Codex / TBD2026-07-17四厂商 reported usage 归一化、显式 estimated 估算、版本化价格匹配、未知价格留空与 BLAKE3-256 语义指纹完成
    TST-001协议 fixture 与 golden testsDone100%Codex / TBD2026-07-17版本化能力矩阵覆盖 OpenAI、Anthropic、Gemini 与 Ollama 的请求、响应、流式、工具、多模态、错误和 Raw fallback;声明均由 fixture 与 golden 验证
    TST-002Gateway/Proxy 集成测试In progress82%Codex / TBD2026-08-25认证 IPC、暂停丢弃、真实模式切换与进程树清理、压缩、流式脱敏、非目标 TLS、HTTP/2 基线一致性及 Windows CA 精确增删通过;真实 sidecar 在客户端取消后保持存活,IPC 停止消费且 72 个事件超过容量 64 时,36 个目标响应仍在 15 秒内完成;真实 ROOT 往返和其余协议一致性待扩展
    TST-003平台故障注入In progress75%Codex / TBD2026-08-25Windows/macOS 强杀恢复、活 owner 防误恢复、sidecar 独立退出回退和端口冲突拒绝已通过;PAC 原状态下应用与同步回滚双重失败会交由独立 watchdog 恢复;macOS 用户拒绝 CA 信任时清理本次新增证书,清理失败返回双重错误,桌面保持 not trusted 并允许重试;macOS helper 故障矩阵待完成
    TST-003平台故障注入In progress80%Codex / TBD2026-08-25Windows/macOS 强杀恢复、活 owner 防误恢复、sidecar 独立退出回退和端口冲突拒绝已通过;PAC 原状态下应用与同步回滚双重失败会交由独立 watchdog 恢复;macOS CA 拒绝恢复及 helper 协议、权限、命令转义和恢复文件校验已覆盖;授权桌面 helper 的真实故障矩阵待完成
    REL-002诊断与支持包In progress75%Codex / Support Owner TBD2026-09-22版本化 JSON 支持包可预览、复制和保存,包含不带请求标识的兼容诊断树;256 KiB code-only journal 与最近 100 条事件接入,排除 Prompt、Raw 和日志详情;支持处理流程待完成
    SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过
    SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过
    S2CAP-001/002、PAR-002/003、APP-003DoneGateway、OpenAI 解析与千条实时工作台全部通过验收
    S3PAR-004、APP-004、TST-001、DAT-002Done双厂商 Inspector、能力矩阵与数据生命周期全部通过验收
    S4CAP-003~005、TST-002In progresssidecar bundle、桌面运行时、协议矩阵、Proxy 取消/背压、跨平台 CA 状态及两平台用户级信任生命周期已实现;签名、其余协议一致性与交互式验收待推进
    S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressSEC-002、CAP-007 完成,IPC 抗阻塞、双模式 OS PID 归因、readiness 身份校验、PAC/CA 拒绝恢复和兼容诊断树已接入;继续 macOS helper 与 OS socket ACL
    S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressSEC-002、CAP-007 完成,IPC 抗阻塞、双模式 OS PID 归因、readiness 身份校验、PAC/CA 拒绝恢复、macOS 授权 helper 和兼容诊断树已接入;继续真实故障验收与 OS socket ACL
    S6PAR-005~007、APP-005Done四厂商适配器、token/成本/指纹、全文搜索和结构/文本 Compare 全部完成
    S7TST-004、性能、可访问性、诊断与保留Not started功能冻结
    S8SEC-004、REL-001/002、安全评审In progresscode-only 诊断与支持包已接入;签名更新、安装回滚、支持流程与安全评审待推进
    2026-07-18Recovery / Fault injectionTST-003 PAC 双重失败恢复完成代理应用部分写入且进程内回滚失败时不再删除 armed journal;owner 主动关闭控制通道并等待独立 watchdog 按预加载 PAC 快照恢复,独立恢复也失败时保留 journal 并返回完整三段错误Codex
    2026-07-18Certificate / Fault injectionTST-003 CA 拒绝恢复完成macOS 用户拒绝信任时删除本次刚加入登录钥匙串的证书;删除也失败时同时报告拒绝与残留清理错误;桌面保持 not trusted、显示原因并允许用户再次触发系统信任流程Codex
    2026-07-18Recovery / macOSCAP-006 特权 helper 协议核心完成helper 只接受无凭据 loopback HTTP 端点和固定恢复文件;控制 socket 为 0600 并同时核对请求 UID 与 PID,只允许单 owner 长连接;正常命令、owner 断连和 helper 崩溃分别由显式恢复、会话 Drop 与独立 root watchdog 恢复原快照Codex
    2026-07-19Desktop / RecoveryCAP-006 macOS 授权 helper 桌面接入静态 AppleScript 对可执行文件、journal、status、socket、endpoint、PID 与 UID 逐项 shell-quote;桌面在受信任 CA 下建立持久特权会话,切换模式、sidecar 退出或 owner 断连均触发恢复;armed journal 下次启动通过严格单次 root 命令恢复,CLI 拒绝缺失、乱序和多余参数Codex
    后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。
    diff --git a/docs/security.html b/docs/security.html index 813f0e4..8e65f6d 100644 --- a/docs/security.html +++ b/docs/security.html @@ -23,7 +23,7 @@

    关键控制

    CA 私钥泄露每设备独立生成、不可同步、最小文件权限、一键卸载 导出泄密强制预览、秘密扫描、稳定占位符、策略版本记录 诊断泄密持久化日志仅含时间戳和受限事件代码;不记录错误详情、Prompt、Raw 或请求标识 - 代理残留设置快照、独立 watchdog、启动修复和卸载恢复 + 代理残留设置快照、独立 watchdog、启动修复和卸载恢复;macOS 仅通过静态且逐参数 shell-quote 的授权命令启动同一受校验可执行文件,私有目录、0600 Unix socket 与 UID/PID 绑定限制 helper 控制面

    证书与代理边界

    From b023dd336b21ff6f0f7b6329eeac3c74e43aeeed Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 20 Jul 2026 22:36:47 +0800 Subject: [PATCH 2/2] fix: own desktop startup test arguments --- apps/desktop/src-tauri/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index b09bcf3..1f0360f 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -162,8 +162,8 @@ fn ensure_finished(arguments: &mut impl Iterator) -> Result<(), mod tests { use super::*; - fn arguments(values: &[&str]) -> impl Iterator + '_ { - values.iter().map(|value| OsString::from(*value)) + fn arguments(values: &[&str]) -> Vec { + values.iter().map(|value| OsString::from(*value)).collect() } #[test]