diff --git a/README.md b/README.md index bc45a16..db62b89 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ cargo run -p codeischeap-desktop-api --bin export-desktop-contract 捕获范围与敏感字段由 `policies/capture-policy.v0.1.json` 定义,公开 schema 位于 `schemas/capture-policy/v0.1.schema.json`。Python sidecar 在 IPC 前执行策略,`crates/core` 在进入持久化前再次拒绝越界请求并删除遗漏凭据。 -sidecar IPC 协议为 `0.3`:仅监听 loopback,使用每次 Proxy 会话重新生成的 256-bit token,认证帧限制为 1 KiB,并要求 `mitmproxy` 来源声明;已接受连接必须在 2 秒内完成认证与数据帧,否则释放连接。认证帧可携带严格校验的临时 loopback 端点用于进程归因,端点不会进入 CaptureEnvelope、持久化或导出。sidecar manifest 分别声明 IPC、Envelope 和 Policy 版本,不兼容 bundle 会在启动前被拒绝。 +sidecar IPC 协议为 `0.4`:仅监听 loopback,使用每次 Proxy 会话重新生成的 256-bit token,认证帧限制为 1 KiB,并要求 `mitmproxy` 来源声明;认证、数据帧、Windows/macOS 首连接 sidecar PID 校验与服务端 ACK 共用 2 秒截止时间,sidecar 在收到 ACK 前保持连接。认证帧可携带严格校验的临时 loopback 端点用于进程归因,端点不会进入 CaptureEnvelope、持久化或导出。sidecar manifest 分别声明 IPC、Envelope 和 Policy 版本,不兼容 bundle 会在启动前被拒绝。 Gateway 和 Proxy 捕获会按显式客户端标签、User-Agent 规则或捕获模式回退生成带置信度的应用归因。Gateway 客户端可设置 `x-codeischeap-client: ` 提供高置信度标签;该内部请求头会在持久化和转发上游前删除。Windows 与 macOS 上的 Gateway 和 Proxy 会用操作系统 TCP 连接表精确匹配客户端 PID,查询失败时保持未知,不做启发式推断;sidecar 自报 PID 会被忽略,用于匹配的临时 socket 端点不会进入持久化或导出。未知客户端会明确显示为低置信度的 `Gateway client` 或 `Proxy client`。 diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ca6ceef..40cbb2f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -13,7 +13,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; use codeischeap_adapters::AdapterRegistry; -use codeischeap_capture_ipc::{CaptureEnvelope, CaptureTransport, receive_one_with_transport}; +use codeischeap_capture_ipc::{ + CaptureEnvelope, CaptureTransport, receive_one_with_transport, + receive_one_with_transport_verified, +}; use codeischeap_capture_policy::CapturePolicy; use codeischeap_core::{ GatewayCaptureError, GatewayCaptureOutcome, IngestError, ingest_envelope, persist_capture, @@ -114,6 +117,7 @@ struct ProxyCaptureContext { listener: TcpListener, token: String, policy: CapturePolicy, + sidecar_process_id: u32, } #[derive(Default)] @@ -149,6 +153,22 @@ impl ProcessIdCache { } } +struct ProxyCaptureSession { + process_ids: ProcessIdCache, + sidecar_process_id: u32, + peer_verified: bool, +} + +impl ProxyCaptureSession { + fn new(sidecar_process_id: u32) -> Self { + Self { + process_ids: ProcessIdCache::default(), + sidecar_process_id, + peer_verified: false, + } + } +} + impl ProxyRuntime { fn shutdown(mut self) -> Result<(), String> { if let Some(shutdown) = self.shutdown.take() { @@ -1153,6 +1173,9 @@ async fn ensure_proxy(app: &AppHandle, state: &DesktopState) -> Result<(), Strin .await .map_err(|error| format!("explicit proxy launch task failed: {error}"))? .map_err(|error| error.to_string())?; + let sidecar_process_id = process + .process_id() + .ok_or_else(|| "explicit proxy process ID is unavailable".to_owned())?; let endpoint = format!("http://{}", process.endpoint()); let generation = state.next_proxy_generation.fetch_add(1, Ordering::Relaxed); state.proxy_session_event_count.store(0, Ordering::Release); @@ -1174,6 +1197,7 @@ async fn ensure_proxy(app: &AppHandle, state: &DesktopState) -> Result<(), Strin listener, token, policy, + sidecar_process_id, }, shutdown_rx, )); @@ -1348,7 +1372,7 @@ fn generate_ipc_token() -> Result { async fn process_proxy_events(context: ProxyCaptureContext, mut shutdown: oneshot::Receiver<()>) { let adapters = AdapterRegistry::default(); let mut captures_since_retention = 0_usize; - let mut process_ids = ProcessIdCache::default(); + let mut session = ProxyCaptureSession::new(context.sidecar_process_id); loop { let result = tokio::select! { _ = &mut shutdown => break, @@ -1359,7 +1383,7 @@ async fn process_proxy_events(context: ProxyCaptureContext, mut shutdown: onesho &adapters, &context.store, &context.capture_active, - &mut process_ids, + &mut session, ) => result, }; match result { @@ -1404,16 +1428,33 @@ async fn receive_and_persist_proxy_capture( adapters: &AdapterRegistry, store: &SharedStore, capture_active: &AtomicBool, - process_ids: &mut ProcessIdCache, + session: &mut ProxyCaptureSession, ) -> Result, ProxyCaptureError> { - let received = receive_one_with_transport(listener, token) - .await - .map_err(IngestError::from)?; + let received = if session.peer_verified { + receive_one_with_transport(listener, token).await + } else { + let expected_process_id = session.sidecar_process_id; + let received = + receive_one_with_transport_verified(listener, token, move |peer, server| async move { + sidecar_peer_matches(peer, server, expected_process_id).await + }) + .await; + if received.is_ok() { + session.peer_verified = true; + } + received + } + .map_err(IngestError::from)?; let mut envelope = received.envelope; let capture_id = envelope.capture_id.clone(); let is_outcome = envelope.outcome.is_some(); let resolved_process_id = resolve_proxy_process(received.transport).await; - apply_proxy_process_id(&mut envelope, policy, process_ids, resolved_process_id); + apply_proxy_process_id( + &mut envelope, + policy, + &mut session.process_ids, + resolved_process_id, + ); let capture = ingest_envelope(envelope, policy)?; if !capture_active.load(Ordering::Acquire) { return Ok(None); @@ -1427,11 +1468,35 @@ async fn receive_and_persist_proxy_capture( .ok_or(ProxyCaptureError::StoreUninitialized)?; persist_capture(store, &capture, parsed.prompt_ir.as_ref())?; if is_outcome { - process_ids.finish(&capture_id); + session.process_ids.finish(&capture_id); } Ok(Some(capture_id)) } +#[cfg(any(windows, target_os = "macos"))] +async fn sidecar_peer_matches( + peer: SocketAddr, + server: SocketAddr, + expected_process_id: u32, +) -> bool { + resolve_proxy_process(Some(CaptureTransport { + client_addr: peer, + server_addr: server, + })) + .await + == Some(expected_process_id) +} + +#[cfg(not(any(windows, target_os = "macos")))] +async fn sidecar_peer_matches( + peer: SocketAddr, + server: SocketAddr, + expected_process_id: u32, +) -> bool { + let _ = (peer, server, expected_process_id); + true +} + async fn resolve_proxy_process(transport: Option) -> Option { let transport = transport?; tokio::task::spawn_blocking(move || { @@ -1695,6 +1760,8 @@ fn show_main_window(app: &tauri::AppHandle) { #[cfg(test)] mod tests { + #[cfg(any(windows, target_os = "macos"))] + use codeischeap_capture_ipc::IpcError; use codeischeap_capture_ipc::{ AttributionConfidence, AttributionSource, CaptureAttribution, CaptureOutcome, CapturedBody, CapturedBodyState, CapturedResponse, ResponseCompleteness, @@ -1703,7 +1770,7 @@ mod tests { use codeischeap_storage::DatabaseKey; use std::net::TcpStream as StdTcpStream; use tempfile::tempdir; - use tokio::io::AsyncWriteExt; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; use super::*; @@ -1960,7 +2027,7 @@ mod tests { let policy = CapturePolicy::load_default().expect("capture policy must load"); let adapters = AdapterRegistry::default(); let active = AtomicBool::new(true); - let mut process_ids = ProcessIdCache::default(); + let mut session = ProxyCaptureSession::new(std::process::id()); let process_listener = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)) .expect("process attribution listener must bind"); let process_server_addr = process_listener @@ -1980,8 +2047,32 @@ mod tests { )) .expect("request fixture must parse"); - send_ipc_capture(address, &token, &request, Some(transport)).await; - assert_eq!( + #[cfg(any(windows, target_os = "macos"))] + { + let mut rejected_session = ProxyCaptureSession::new(u32::MAX); + let (acknowledged, result) = tokio::join!( + send_ipc_capture(address, &token, &request, Some(transport)), + receive_and_persist_proxy_capture( + &listener, + &token, + &policy, + &adapters, + &store, + &active, + &mut rejected_session, + ) + ); + let error = result.expect_err("an unexpected IPC process must be rejected"); + assert!(!acknowledged); + assert!(matches!( + error, + ProxyCaptureError::Ingest(IngestError::Ipc(IpcError::UnauthorizedPeer)) + )); + assert!(!rejected_session.peer_verified); + } + + let (acknowledged, result) = tokio::join!( + send_ipc_capture(address, &token, &request, Some(transport)), receive_and_persist_proxy_capture( &listener, &token, @@ -1989,12 +2080,15 @@ mod tests { &adapters, &store, &active, - &mut process_ids, + &mut session, ) - .await - .expect("request must persist"), + ); + assert!(acknowledged); + assert_eq!( + result.expect("request must persist"), Some(request.capture_id.clone()) ); + assert!(session.peer_verified); let mut response = request.clone(); response.outcome = Some(CaptureOutcome::Response(CapturedResponse { @@ -2007,24 +2101,26 @@ mod tests { duration_ms: 42, completeness: ResponseCompleteness::Complete, })); - send_ipc_capture(address, &token, &response, None).await; - receive_and_persist_proxy_capture( - &listener, - &token, - &policy, - &adapters, - &store, - &active, - &mut process_ids, - ) - .await - .expect("response must persist"); + let (acknowledged, result) = tokio::join!( + send_ipc_capture(address, &token, &response, None), + receive_and_persist_proxy_capture( + &listener, + &token, + &policy, + &adapters, + &store, + &active, + &mut session, + ) + ); + assert!(acknowledged); + result.expect("response must persist"); active.store(false, Ordering::Release); let mut paused = request.clone(); paused.capture_id = "paused_capture".to_owned(); - send_ipc_capture(address, &token, &paused, None).await; - assert_eq!( + let (acknowledged, result) = tokio::join!( + send_ipc_capture(address, &token, &paused, None), receive_and_persist_proxy_capture( &listener, &token, @@ -2032,10 +2128,12 @@ mod tests { &adapters, &store, &active, - &mut process_ids, + &mut session, ) - .await - .expect("paused event must be accepted and discarded"), + ); + assert!(acknowledged); + assert_eq!( + result.expect("paused event must be accepted and discarded"), None ); @@ -2062,13 +2160,13 @@ mod tests { token: &str, envelope: &CaptureEnvelope, transport: Option, - ) { + ) -> bool { let mut stream = TcpStream::connect(address) .await .expect("IPC client must connect"); let auth = serde_json::json!({ "protocol": "codeischeap.capture-ipc", - "version": "0.3", + "version": "0.4", "origin": "mitmproxy", "token": token, "transport": transport, @@ -2077,7 +2175,16 @@ mod tests { .write_all(format!("{auth}\n{}\n", serde_json::to_string(envelope).unwrap()).as_bytes()) .await .expect("IPC frames must write"); - stream.shutdown().await.expect("IPC client must close"); + let mut acknowledgement = String::new(); + if BufReader::new(stream) + .read_line(&mut acknowledgement) + .await + .is_err() + { + return false; + } + serde_json::from_str::(&acknowledgement).ok() + == Some(serde_json::json!({"status": "accepted"})) } #[test] diff --git a/crates/capture-ipc/src/lib.rs b/crates/capture-ipc/src/lib.rs index cfebc64..4ca8cd3 100644 --- a/crates/capture-ipc/src/lib.rs +++ b/crates/capture-ipc/src/lib.rs @@ -5,6 +5,7 @@ //! confused with data that may later be persisted. use std::fmt; +use std::future::{Future, ready}; use std::io; use std::net::SocketAddr; use std::time::Duration; @@ -12,18 +13,19 @@ use std::time::Duration; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq as _; -use tokio::io::{AsyncBufRead, AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::time::timeout; pub const CAPTURE_ENVELOPE_VERSION: &str = "0.1"; pub const IPC_PROTOCOL: &str = "codeischeap.capture-ipc"; -pub const IPC_PROTOCOL_VERSION: &str = "0.3"; +pub const IPC_PROTOCOL_VERSION: &str = "0.4"; pub const IPC_ORIGIN_MITMPROXY: &str = "mitmproxy"; pub const CLIENT_LABEL_HEADER: &str = "x-codeischeap-client"; pub const MAX_AUTH_FRAME_BYTES: usize = 1024; pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; pub const DEFAULT_CONNECTION_DEADLINE: Duration = Duration::from_secs(2); +const ACCEPTED_FRAME: &[u8] = b"{\"status\":\"accepted\"}\n"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -202,6 +204,7 @@ pub enum IpcError { UnsupportedProtocol, UnsupportedOrigin, Unauthorized, + UnauthorizedPeer, InvalidTransport, UnsupportedEnvelopeVersion(String), } @@ -225,6 +228,9 @@ impl fmt::Display for IpcError { Self::UnsupportedProtocol => write!(formatter, "capture IPC protocol is unsupported"), Self::UnsupportedOrigin => write!(formatter, "capture IPC origin is unsupported"), Self::Unauthorized => write!(formatter, "capture IPC authentication failed"), + Self::UnauthorizedPeer => { + write!(formatter, "capture IPC peer process is unauthorized") + } Self::InvalidTransport => { write!(formatter, "capture IPC transport context is invalid") } @@ -270,6 +276,24 @@ pub async fn receive_one_with_transport( receive_one_with_transport_deadline(listener, expected_token, DEFAULT_CONNECTION_DEADLINE).await } +pub async fn receive_one_with_transport_verified( + listener: &TcpListener, + expected_token: &str, + verify_peer: F, +) -> Result +where + F: FnOnce(SocketAddr, SocketAddr) -> Fut, + Fut: Future, +{ + receive_one_with_transport_verifier_deadline( + listener, + expected_token, + DEFAULT_CONNECTION_DEADLINE, + verify_peer, + ) + .await +} + pub async fn receive_one_with_deadline( listener: &TcpListener, expected_token: &str, @@ -287,6 +311,25 @@ pub async fn receive_one_with_transport_deadline( expected_token: &str, connection_deadline: Duration, ) -> Result { + receive_one_with_transport_verifier_deadline( + listener, + expected_token, + connection_deadline, + |_, _| ready(true), + ) + .await +} + +async fn receive_one_with_transport_verifier_deadline( + listener: &TcpListener, + expected_token: &str, + connection_deadline: Duration, + verify_peer: F, +) -> Result +where + F: FnOnce(SocketAddr, SocketAddr) -> Fut, + Fut: Future, +{ if !listener.local_addr()?.ip().is_loopback() { return Err(IpcError::NonLoopbackListener); } @@ -295,11 +338,17 @@ pub async fn receive_one_with_transport_deadline( if !peer.ip().is_loopback() { return Err(IpcError::NonLoopbackPeer); } + let server = stream.local_addr()?; - timeout( - connection_deadline, - receive_from_reader_with_transport(&mut BufReader::new(stream), expected_token), - ) + timeout(connection_deadline, async move { + if !verify_peer(peer, server).await { + return Err(IpcError::UnauthorizedPeer); + } + let mut reader = BufReader::new(stream); + let capture = receive_from_reader_with_transport(&mut reader, expected_token).await?; + reader.get_mut().write_all(ACCEPTED_FRAME).await?; + Ok(capture) + }) .await .map_err(|_| IpcError::ConnectionDeadlineExceeded)? } diff --git a/crates/capture-ipc/tests/contract.rs b/crates/capture-ipc/tests/contract.rs index 30dabb6..cfd7b63 100644 --- a/crates/capture-ipc/tests/contract.rs +++ b/crates/capture-ipc/tests/contract.rs @@ -3,10 +3,10 @@ use codeischeap_capture_ipc::{ CapturedBody, CapturedBodyState, CapturedField, CapturedRequest, CapturedResponse, IPC_ORIGIN_MITMPROXY, IPC_PROTOCOL, IPC_PROTOCOL_VERSION, IpcError, MAX_AUTH_FRAME_BYTES, ResponseCompleteness, receive_from_reader, receive_from_reader_with_transport, - receive_one_with_deadline, + receive_one_with_deadline, receive_one_with_transport_verified, }; use schemars::schema_for; -use tokio::io::{AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use std::time::Duration; @@ -99,7 +99,7 @@ async fn rejects_the_previous_transport_protocol() { let mut reader = framed_reader_with_auth( "synthetic-token", IPC_ORIGIN_MITMPROXY, - "0.2", + "0.3", &expected, None, ) @@ -278,6 +278,12 @@ async fn stalled_connection_expires_and_the_next_sidecar_can_deliver() { ) .await .expect("sidecar frames must write"); + let mut acknowledgement = String::new(); + BufReader::new(stream) + .read_line(&mut acknowledgement) + .await + .expect("acknowledgement must read"); + assert_eq!(acknowledgement, "{\"status\":\"accepted\"}\n"); }); let received = receive_one_with_deadline(&listener, "synthetic-token", Duration::from_secs(1)) @@ -287,6 +293,34 @@ async fn stalled_connection_expires_and_the_next_sidecar_can_deliver() { assert_eq!(received, expected); } +#[tokio::test] +async fn rejects_an_unauthorized_peer_before_reading_auth() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener must bind"); + let address = listener.local_addr().expect("listener address"); + let sender = tokio::spawn(async move { + TcpStream::connect(address) + .await + .expect("unauthorized peer must connect") + }); + + let error = receive_one_with_transport_verified( + &listener, + "synthetic-token", + |peer, server| async move { + assert!(peer.ip().is_loopback()); + assert_eq!(server, address); + false + }, + ) + .await + .expect_err("unauthorized peer must be rejected"); + let _stream = sender.await.expect("sender task must complete"); + + assert!(matches!(error, IpcError::UnauthorizedPeer)); +} + #[test] fn mitmproxy_fixture_matches_the_rust_contract() { let envelope: CaptureEnvelope = diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b9feb1f..d210628 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -444,7 +444,7 @@ mod tests { let (mut writer, reader) = tokio::io::duplex(16 * 1024); let auth = serde_json::json!({ "protocol": "codeischeap.capture-ipc", - "version": "0.3", + "version": "0.4", "origin": "mitmproxy", "token": token, }); diff --git a/crates/sidecar-runtime/src/lib.rs b/crates/sidecar-runtime/src/lib.rs index 45381a5..c422633 100644 --- a/crates/sidecar-runtime/src/lib.rs +++ b/crates/sidecar-runtime/src/lib.rs @@ -83,7 +83,7 @@ use windows_sys::Win32::System::SystemServices::{ use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; pub const SIDECAR_MANIFEST_VERSION: &str = "0.1"; -pub const CAPTURE_IPC_PROTOCOL_VERSION: &str = "0.3"; +pub const CAPTURE_IPC_PROTOCOL_VERSION: &str = "0.4"; pub const CAPTURE_ENVELOPE_VERSION: &str = "0.1"; pub const CAPTURE_POLICY_VERSION: &str = "0.1"; pub const MITMPROXY_VERSION: &str = "12.2.3"; @@ -1325,6 +1325,11 @@ impl SidecarProcess { self.endpoint } + #[must_use] + pub fn process_id(&self) -> Option { + self.child.as_ref().map(Child::id) + } + pub fn try_wait(&mut self) -> Result, SidecarError> { let Some(child) = self.child.as_mut() else { return Ok(None); @@ -2172,7 +2177,7 @@ MAoGCCqGSM49BAMCA0gAMEUCIQC1PB8+NumezrQf5unFGhVeufUcyw/sjH6p1aqs "max_bytes": 1024 }, "capture_contract": { - "ipc_protocol": "0.3", + "ipc_protocol": "0.4", "envelope": "0.1", "policy": "0.1", "policy_file": "capture-policy.v0.1.json", @@ -2467,6 +2472,11 @@ fn main() { .launch(&config, Duration::from_secs(5)) .expect("verified fixture must start"); assert_eq!(process.endpoint(), config.listen_addr); + assert!( + process + .process_id() + .is_some_and(|process_id| process_id != 0) + ); let environment = fs::read_to_string(confdir.join("observed-environment.txt")).unwrap(); assert!(environment.contains(&format!("CIC_CAPTURE_IPC_TOKEN={token}"))); assert!(environment.contains("CIC_CAPTURE_HOSTS=api.openai.com")); diff --git a/docs/progress.html b/docs/progress.html index d5eb6af..b446587 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -89,7 +89,7 @@

5. 当前迭代:S5 / S6

DAT-002写入、查询、保留与磁盘压力Done100%Codex / TBD2026-07-15批量写入原子提交;默认保留 30 天/50,000 条并以 500 条事务清理;低于 256 MiB 或 SQLITE_FULL 时暂停记录但保持 Gateway 转发 SEC-001范围策略和凭据 scrubberDone100%Codex / Security Reviewer TBD2026-07-18请求与响应 header/JSON body 双层脱敏;canary 在 DB、WAL、诊断日志、临时文件和支持包中零命中 SEC-002脱敏与导出Done100%Codex / Security Reviewer TBD2026-07-18单请求、筛选批量及支持包均强制扫描、完整预览、SHA-256 防陈旧和只新建文件;版本化 corpus 以 36 个字段名、17 类文本 secret 及近似反例覆盖 Rust/Python/Web - SEC-003本地 IPC 与插件隔离In progress65%Codex / Security Reviewer TBD2026-08-25IPC 0.3 使用 loopback、会话级 256-bit token、常量时间比较、mitmproxy 来源声明、1 KiB 认证帧和 2 秒连接截止时间;临时归因端点限制为同地址族 loopback 且不进入 Envelope,bundle 启动前校验协议版本;OS socket ACL 与 WASI 权限待完成 + SEC-003本地 IPC 与插件隔离In progress72%Codex / Security Reviewer TBD2026-08-25IPC 0.4 使用 loopback、会话级 256-bit token、常量时间比较、mitmproxy 来源声明、1 KiB 认证帧和 2 秒端到端截止时间;Windows/macOS 首连接绑定已启动 sidecar PID,并以服务端 ACK 消除短连接竞态,临时端点不进入 Envelope;用户级 socket ACL、Linux peer 绑定与 WASI 权限待完成 PAR-001Prompt IR v0.1 与首批 fixturesDone100%Codex / TBD2026-07-14OpenAI/Anthropic fixtures、校验与 schema 完成 PAR-002解析器注册表与检测链Done100%Codex / TBD2026-07-15置信度排序、错误/panic 隔离、IR 校验、partial issues 与 Raw fallback 完成 PAR-003OpenAI-compatible 适配器Done100%Codex / TBD2026-07-15Responses、Chat、Completions、多模态与 tools golden fixtures 通过 @@ -216,6 +216,7 @@

11. 决策与变更记录

2026-07-18Security / DiagnosticsSEC-001 完成,REL-002 推进运行时诊断仅持久化时间戳和受限事件代码,journal 上限 256 KiB;支持包最多携带最近 100 条事件且排除详情,端到端 canary 在 DB、WAL、日志、临时文件和支持包中零命中Codex 2026-07-18Security / IPCSEC-003 IPC 会话加固完成首段IPC 0.2 将认证帧限制为 1 KiB,使用会话级随机 token 与常量时间比较,要求 mitmproxy 来源声明并对连接设置 2 秒截止时间;Rust/Python bundle verifier 在启动前拒绝旧协议 sidecarCodex 2026-07-18Capture / SecurityCAP-007 完成、SEC-003 推进IPC 0.3 在认证帧中传递严格 loopback 临时端点,桌面端忽略 sidecar PID 并用 OS socket 表精确归因 Proxy 进程;端点不进入 Envelope、数据库或导出,查询失败保持未知Codex + 2026-07-18Security / IPCSEC-003 sidecar peer 绑定完成首段IPC 0.4 让 sidecar 等待服务端 ACK,从协议层保持首连接直到 Windows/macOS OS socket 表完成 PID 核对;不匹配、查询失败或 2 秒超时只拒绝捕获,代理转发继续,后续连接仍受会话 token 保护Codex 后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。 diff --git a/docs/security.html b/docs/security.html index 7da16a1..17d2b94 100644 --- a/docs/security.html +++ b/docs/security.html @@ -19,7 +19,7 @@

关键控制

- + diff --git a/docs/threat-model.html b/docs/threat-model.html index e16321a..d1c24ed 100644 --- a/docs/threat-model.html +++ b/docs/threat-model.html @@ -45,7 +45,7 @@

威胁台账

风险控制
过度采集AI host/path allowlist;非目标流量只 tunnel
密钥落盘删除 Authorization、Cookie、API key header 和敏感 query
本机进程伪造或阻塞 IPCIPC 0.3 仅限 loopback,使用会话级随机 token、常量时间比较、来源声明、1 KiB 认证帧与 2 秒连接截止时间;临时进程归因端点不进入 Envelope 或存储
本机进程伪造或阻塞 IPCIPC 0.4 仅限 loopback,使用会话级随机 token、常量时间比较、来源声明、1 KiB 认证帧与 2 秒端到端截止时间;Windows/macOS 首连接绑定已启动 sidecar PID,sidecar 等待服务端 ACK;临时进程归因端点不进入 Envelope 或存储
CA 私钥泄露每设备独立生成、不可同步、最小文件权限、一键卸载
导出泄密强制预览、秘密扫描、稳定占位符、策略版本记录
诊断泄密持久化日志仅含时间戳和受限事件代码;不记录错误详情、Prompt、Raw 或请求标识
- + diff --git a/sidecars/mitmproxy/codeischeap_addon.py b/sidecars/mitmproxy/codeischeap_addon.py index 00f5135..53b2f83 100644 --- a/sidecars/mitmproxy/codeischeap_addon.py +++ b/sidecars/mitmproxy/codeischeap_addon.py @@ -16,7 +16,7 @@ IPC_PROTOCOL = "codeischeap.capture-ipc" -IPC_PROTOCOL_VERSION = "0.3" +IPC_PROTOCOL_VERSION = "0.4" IPC_ORIGIN = "mitmproxy" CAPTURE_ENVELOPE_VERSION = "0.1" CAPTURE_POLICY_VERSION = "0.1" @@ -479,7 +479,7 @@ def build_failure_envelope(flow: Any) -> dict[str, Any]: class IpcConfig: - def __init__(self, host: str, port: int, token: str, timeout_seconds: float = 0.5) -> None: + def __init__(self, host: str, port: int, token: str, timeout_seconds: float = 2.0) -> None: self.host = host self.port = port self.token = token @@ -550,6 +550,9 @@ def send_envelope( ).encode("utf-8") with socket.create_connection((config.host, config.port), timeout=config.timeout_seconds) as connection: connection.sendall(frames) + acknowledgement = connection.makefile("rb").readline(256) + if json.loads(acknowledgement) != {"status": "accepted"}: + raise ValueError("capture IPC acknowledgement is invalid") class IpcEmitter: diff --git a/sidecars/mitmproxy/package_sidecar.py b/sidecars/mitmproxy/package_sidecar.py index e45de24..aa27d28 100644 --- a/sidecars/mitmproxy/package_sidecar.py +++ b/sidecars/mitmproxy/package_sidecar.py @@ -316,7 +316,7 @@ def main() -> None: "max_bytes": MAX_ACCEPTABLE_BYTES, }, "capture_contract": { - "ipc_protocol": "0.3", + "ipc_protocol": "0.4", "envelope": "0.1", "policy": "0.1", "policy_file": POLICY_FILENAME, diff --git a/sidecars/mitmproxy/tests/test_addon.py b/sidecars/mitmproxy/tests/test_addon.py index 4167169..eb912bf 100644 --- a/sidecars/mitmproxy/tests/test_addon.py +++ b/sidecars/mitmproxy/tests/test_addon.py @@ -398,6 +398,7 @@ def accept() -> None: with connection: stream = connection.makefile("rb") received.extend([stream.readline(), stream.readline()]) + connection.sendall(b'{"status":"accepted"}\n') worker = threading.Thread(target=accept) worker.start() @@ -411,7 +412,7 @@ def accept() -> None: self.assertFalse(worker.is_alive()) auth = json.loads(received[0]) captured = json.loads(received[1]) - self.assertEqual(auth["version"], "0.3") + self.assertEqual(auth["version"], "0.4") self.assertEqual(auth["origin"], "mitmproxy") self.assertEqual(auth["token"], "synthetic-token") self.assertEqual(auth["transport"], transport) diff --git a/sidecars/mitmproxy/tests/test_packaging.py b/sidecars/mitmproxy/tests/test_packaging.py index d41056a..407ab85 100644 --- a/sidecars/mitmproxy/tests/test_packaging.py +++ b/sidecars/mitmproxy/tests/test_packaging.py @@ -41,7 +41,7 @@ def write_bundle(bundle: Path) -> dict: "max_bytes": 1024, }, "capture_contract": { - "ipc_protocol": "0.3", + "ipc_protocol": "0.4", "envelope": "0.1", "policy": "0.1", "policy_file": "capture-policy.v0.1.json", @@ -102,7 +102,7 @@ def test_bundle_validation_checks_hash_contract_and_signature_gate(self) -> None with self.assertRaisesRegex(ValueError, "contract version"): validate_bundle(bundle) - manifest["capture_contract"]["ipc_protocol"] = "0.3" + manifest["capture_contract"]["ipc_protocol"] = "0.4" del manifest["integration_probe"]["http2_preserved"] (bundle / "sidecar-manifest.json").write_text(json.dumps(manifest)) with self.assertRaisesRegex(ValueError, "integration probe did not pass"): diff --git a/sidecars/mitmproxy/verify_packaged_sidecar.py b/sidecars/mitmproxy/verify_packaged_sidecar.py index 25cae9b..c918153 100644 --- a/sidecars/mitmproxy/verify_packaged_sidecar.py +++ b/sidecars/mitmproxy/verify_packaged_sidecar.py @@ -565,6 +565,7 @@ def receive_ipc() -> None: with connection: stream = connection.makefile("rb") ipc_frames.append((stream.readline(), stream.readline())) + connection.sendall(b'{"status":"accepted"}\n') ipc_thread = threading.Thread(target=receive_ipc, daemon=True) ipc_thread.start() diff --git a/sidecars/mitmproxy/verify_sidecar_bundle.py b/sidecars/mitmproxy/verify_sidecar_bundle.py index f624b08..70041c1 100644 --- a/sidecars/mitmproxy/verify_sidecar_bundle.py +++ b/sidecars/mitmproxy/verify_sidecar_bundle.py @@ -14,7 +14,7 @@ MANIFEST_FILENAME = "sidecar-manifest.json" EXPECTED_SCHEMA_VERSION = "0.1" EXPECTED_CAPTURE_CONTRACT = { - "ipc_protocol": "0.3", + "ipc_protocol": "0.4", "envelope": "0.1", "policy": "0.1", }
ID威胁现有证据剩余控制 / 门槛
TM-01鉴权凭据进入 IPC、日志或 DB共享敏感字段策略;sidecar 与 Core 双层 scrubber;DB、WAL、诊断日志、临时文件和支持包端到端 canary 零命中独立安全评审与 corpus 持续维护
TM-02捕获非目标应用或端点Rust/Python 共用精确 host、POST method 与 path pattern 策略Profile UI 与自定义端点策略管理
TM-03本机其他进程伪造、读取或阻塞 IPCIPC 0.3 仅限 loopback;每次 Proxy 会话生成 256-bit token 并常量时间比较;认证帧 1 KiB、Envelope 4 MiB;要求 mitmproxy 来源声明;连接 2 秒未完成即释放;临时进程归因端点严格限制为同地址族 loopback 且不进入 Envelope;旧协议 bundle 启动前拒绝用户级 socket ACL;插件启用后的 WASI 最小权限
TM-03本机其他进程伪造、读取或阻塞 IPCIPC 0.4 仅限 loopback;每次 Proxy 会话生成 256-bit token 并常量时间比较;Windows/macOS 首连接通过 OS socket 表绑定已启动 sidecar PID,sidecar 在服务端 ACK 前保持连接;认证帧 1 KiB、Envelope 4 MiB;要求 mitmproxy 来源声明;连接全流程 2 秒未完成即释放;临时归因端点严格限制为同地址族 loopback 且不进入 Envelope;旧协议 bundle 启动前拒绝用户级 socket ACL;Linux peer 绑定;插件启用后的 WASI 最小权限
TM-04数据库文件被离线读取SQLCipher、WAL、迁移、加密备份、错误密钥、DB/WAL canary 与跨平台 OS 凭据库 CI锁定状态与 key 丢失恢复体验
TM-05本地 CA 私钥泄露或残留信任每设备 CA、跨平台私钥权限检查、指纹 UI、Windows CurrentUser 与 macOS User 幂等卸载已实现两平台交互式验收;发布前独立安全评审
TM-06崩溃后系统代理残留Windows/macOS 强杀恢复实验;Windows 桌面已接入私有 journal、watchdog、启动恢复和活 owner 校验;sidecar 单独退出会按代际恢复代理并回退 GatewaymacOS 特权 helper;端口冲突、PAC 与 CA 拒绝故障注入