From b61c7411fd440925855ed7e3874bc30facd14cbf Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:53:25 +0800 Subject: [PATCH 1/5] feat(rust): fenced whole-session deletion, the Swift SessionDeleter's counterpart `agent-session-core::deletion` removes a session's own log files at a host's explicit request, and nothing else: targets must resolve strictly below one of the provider's roots under the given home, a symlinked target is refused, the file is re-parsed for its session id right before removal, and only Codex and Claude sessions are candidates. Claude's sidecar directory of the same stem goes with the file; directories are walked without following links. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 12 + .../crates/agent-session-core/src/deletion.rs | 354 ++++++++++++++++++ .../agent-session-core/src/discovery.rs | 30 ++ .../rust/crates/agent-session-core/src/lib.rs | 7 +- 4 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 implementations/rust/crates/agent-session-core/src/deletion.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dd24843..c68069f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **Rust: `deletion` module.** `agent-session-core` can now remove a whole + session's own log files — the Rust counterpart of the Swift + `SessionDeleter`, fenced the same way: every target must resolve strictly + below one of the provider's roots under the given home, a target that is + itself a symlink is refused, the session file is re-parsed immediately + before removal so a stale summary cannot delete a different session, and + only Codex and Claude sessions (`supports_deletion`) are ever candidates. + Claude removes the sidecar directory of the same stem when it exists; a + directory is walked without following links. Hosts call it only at the + person's explicit request. + ## [0.7.0] - 2026-08-31 Swift and Rust become peer implementation lanes. The repository now says out diff --git a/implementations/rust/crates/agent-session-core/src/deletion.rs b/implementations/rust/crates/agent-session-core/src/deletion.rs new file mode 100644 index 0000000..bad6a63 --- /dev/null +++ b/implementations/rust/crates/agent-session-core/src/deletion.rs @@ -0,0 +1,354 @@ +//! Whole-session deletion, fenced the way the Swift `SessionDeleter` is. +//! +//! This is the one place the crate removes anything, and it removes only a +//! session's own log files, only when a host asks for that session by name. +//! Every target is canonicalized and must resolve strictly below one of the +//! provider's roots under the given home; a target that is itself a symlink +//! is refused; and the session file is re-parsed immediately before removal +//! so a stale summary cannot delete a different session. Nothing here edits +//! the contents of a file, and nothing outside the provider roots is ever +//! touched. + +use std::path::{Path, PathBuf}; + +use crate::discovery; +use crate::provider::SessionProvider; + +/// What a host knows about the session it wants gone. `source_path` is the +/// file discovery or the index reported for it; `session_id` is the id that +/// file must still carry when re-parsed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionToDelete { + pub provider: SessionProvider, + pub session_id: String, + pub source_path: PathBuf, +} + +/// The paths one deletion removes, and the file whose re-parse must still +/// name the expected session before anything is removed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeletionPlan { + pub provider: SessionProvider, + pub paths_to_remove: Vec, + pub validation_source_path: PathBuf, + pub expected_session_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DeleteError { + #[error("this provider's sessions are read by another app and are never deleted here")] + ProviderIsReadOnly, + #[error("the session's files do not resolve below one of the provider's roots")] + PathEscapesProviderRoot, + #[error("one of the session's files is a symbolic link")] + SymlinkedTarget, + #[error("the session file could not be read back before removal")] + ValidationUnreadable, + #[error("the session file no longer names the session that was asked for")] + SessionIdMismatch, + #[error("removing {0} failed")] + RemovalFailed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeleteOutcome { + Succeeded(SessionToDelete), + Failed(SessionToDelete, DeleteError), +} + +/// The provider roots deletion is fenced to, under `home`: the same +/// directories discovery reads, resolved through symlinks so the fence and +/// the targets are compared on equal terms. A root that does not exist is +/// simply absent. +pub fn provider_roots(provider: SessionProvider, home: &Path) -> Vec { + let candidates: &[&[&str]] = match provider { + SessionProvider::Codex => &[&[".codex", "sessions"], &[".codex", "archived_sessions"]], + SessionProvider::Claude => &[&[".claude", "projects"], &[".config", "claude", "projects"]], + _ => &[], + }; + candidates + .iter() + .filter_map(|components| { + let mut path = home.to_path_buf(); + for component in components.iter() { + path.push(component); + } + std::fs::canonicalize(path).ok() + }) + .collect() +} + +/// What deleting this session removes. Codex: the rollout file. Claude: the +/// session file and, when present, the sidecar directory of the same stem +/// (sub-agent logs). Other providers are refused: their stores belong to +/// another app. +pub fn plan(session: &SessionToDelete) -> Result { + if !session.provider.supports_deletion() { + return Err(DeleteError::ProviderIsReadOnly); + } + let mut paths = vec![session.source_path.clone()]; + if session.provider == SessionProvider::Claude { + let sidecar = session.source_path.with_extension(""); + if std::fs::symlink_metadata(&sidecar).is_ok_and(|meta| meta.file_type().is_dir()) { + paths.push(sidecar); + } + } + Ok(DeletionPlan { + provider: session.provider, + paths_to_remove: paths, + validation_source_path: session.source_path.clone(), + expected_session_id: session.session_id.clone(), + }) +} + +/// Delete each session, one at a time, reporting per session. A failure on +/// one never stops the others, and nothing about a failed session is +/// removed — the checks all run before the first `remove`. +pub fn delete(home: &Path, sessions: &[SessionToDelete]) -> Vec { + sessions + .iter() + .map(|session| match delete_one(home, session) { + Ok(()) => DeleteOutcome::Succeeded(session.clone()), + Err(error) => DeleteOutcome::Failed(session.clone(), error), + }) + .collect() +} + +fn delete_one(home: &Path, session: &SessionToDelete) -> Result<(), DeleteError> { + let plan = plan(session)?; + if plan.paths_to_remove.is_empty() { + return Err(DeleteError::ValidationUnreadable); + } + let roots = provider_roots(session.provider, home); + if roots.is_empty() { + return Err(DeleteError::PathEscapesProviderRoot); + } + for target in plan + .paths_to_remove + .iter() + .chain(std::iter::once(&plan.validation_source_path)) + { + if is_symlink(target) { + return Err(DeleteError::SymlinkedTarget); + } + if !is_contained(target, &roots) { + return Err(DeleteError::PathEscapesProviderRoot); + } + } + let reparsed = reparse_session_id(session.provider, &plan.validation_source_path) + .ok_or(DeleteError::ValidationUnreadable)?; + if !reparsed.eq_ignore_ascii_case(&plan.expected_session_id) { + return Err(DeleteError::SessionIdMismatch); + } + for path in &plan.paths_to_remove { + let Ok(meta) = std::fs::symlink_metadata(path) else { + continue; + }; + let result = if meta.file_type().is_dir() { + remove_dir_without_following_links(path) + } else { + std::fs::remove_file(path) + }; + if result.is_err() { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + return Err(DeleteError::RemovalFailed(name)); + } + } + Ok(()) +} + +/// Strictly below one of `roots` after symlink resolution: the root itself +/// never qualifies, and a link pointing outside the provider's tree resolves +/// out of every root's prefix. +fn is_contained(path: &Path, roots: &[PathBuf]) -> bool { + let Ok(resolved) = std::fs::canonicalize(path) else { + return false; + }; + roots + .iter() + .any(|root| resolved != *root && resolved.starts_with(root)) +} + +fn is_symlink(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) +} + +/// Remove a directory tree without ever following a symlink: a link inside +/// is unlinked, never descended. +fn remove_dir_without_following_links(dir: &Path) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let meta = std::fs::symlink_metadata(&path)?; + if meta.file_type().is_dir() { + remove_dir_without_following_links(&path)?; + } else { + std::fs::remove_file(&path)?; + } + } + std::fs::remove_dir(dir) +} + +/// The session id the file names right now, read the way discovery reads it. +fn reparse_session_id(provider: SessionProvider, path: &Path) -> Option { + match provider { + SessionProvider::Codex => discovery::codex_session_id(path), + SessionProvider::Claude => discovery::claude_session_id(path), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn home() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + fn write(path: &Path, lines: &[&str]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut file = std::fs::File::create(path).unwrap(); + for line in lines { + writeln!(file, "{line}").unwrap(); + } + } + + const CODEX_ID: &str = "0199c4f8-77a1-7e52-b3d8-0a6f6f1e1d2c"; + const CLAUDE_ID: &str = "6d2f1c3a-9b4e-4c1d-8f2a-1b2c3d4e5f60"; + + fn codex_session(home: &Path) -> SessionToDelete { + let path = home + .join(".codex/sessions/2026/09/03") + .join(format!("rollout-2026-09-03T10-00-00-{CODEX_ID}.jsonl")); + write(&path, &[&format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{CODEX_ID}\",\"cwd\":\"/Users/example/app\"}}}}")]); + SessionToDelete { + provider: SessionProvider::Codex, + session_id: CODEX_ID.to_string(), + source_path: path, + } + } + + fn claude_session(home: &Path) -> SessionToDelete { + let path = home + .join(".claude/projects/-Users-example-app") + .join(format!("{CLAUDE_ID}.jsonl")); + write(&path, &[&format!("{{\"type\":\"user\",\"sessionId\":\"{CLAUDE_ID}\",\"cwd\":\"/Users/example/app\",\"message\":{{\"role\":\"user\",\"content\":\"hi\"}}}}")]); + SessionToDelete { + provider: SessionProvider::Claude, + session_id: CLAUDE_ID.to_string(), + source_path: path, + } + } + + #[test] + fn deletes_a_codex_rollout_and_nothing_else() { + let dir = home(); + let session = codex_session(dir.path()); + let neighbour = session.source_path.with_file_name( + "rollout-2026-09-03T11-00-00-1199c4f8-77a1-7e52-b3d8-0a6f6f1e1d2d.jsonl", + ); + write(&neighbour, &["{\"type\":\"session_meta\",\"payload\":{\"id\":\"1199c4f8-77a1-7e52-b3d8-0a6f6f1e1d2d\"}}"]); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert_eq!(outcomes, vec![DeleteOutcome::Succeeded(session.clone())]); + assert!(!session.source_path.exists()); + assert!(neighbour.exists()); + } + + #[test] + fn deletes_a_claude_session_with_its_sidecar_directory() { + let dir = home(); + let session = claude_session(dir.path()); + let sidecar = session.source_path.with_extension(""); + write(&sidecar.join("agent-1.jsonl"), &["{}"]); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!(outcomes[0], DeleteOutcome::Succeeded(_))); + assert!(!session.source_path.exists()); + assert!(!sidecar.exists()); + } + + #[test] + fn refuses_a_file_outside_the_provider_roots() { + let dir = home(); + let mut session = codex_session(dir.path()); + let elsewhere = dir.path().join("elsewhere.jsonl"); + std::fs::copy(&session.source_path, &elsewhere).unwrap(); + session.source_path = elsewhere.clone(); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::PathEscapesProviderRoot) + )); + assert!(elsewhere.exists()); + } + + #[cfg(unix)] + #[test] + fn refuses_a_symlinked_target_and_leaves_its_target_alone() { + let dir = home(); + let real = codex_session(dir.path()); + let link = real + .source_path + .with_file_name(format!("rollout-2026-09-03T12-00-00-{CODEX_ID}.jsonl")); + std::os::unix::fs::symlink(&real.source_path, &link).unwrap(); + let via_link = SessionToDelete { + source_path: link.clone(), + ..real.clone() + }; + let outcomes = delete(dir.path(), std::slice::from_ref(&via_link)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::SymlinkedTarget) + )); + assert!(real.source_path.exists()); + assert!(std::fs::symlink_metadata(&link).is_ok()); + } + + #[test] + fn refuses_when_the_file_names_a_different_session() { + let dir = home(); + let mut session = codex_session(dir.path()); + session.session_id = "ffffffff-0000-4000-8000-000000000000".to_string(); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::SessionIdMismatch) + )); + assert!(session.source_path.exists()); + } + + #[test] + fn refuses_read_only_providers_before_looking_at_paths() { + let dir = home(); + let session = SessionToDelete { + provider: SessionProvider::Cursor, + session_id: "x".into(), + source_path: dir.path().join("x"), + }; + assert_eq!(plan(&session), Err(DeleteError::ProviderIsReadOnly)); + assert!(matches!( + delete(dir.path(), &[session])[0], + DeleteOutcome::Failed(_, DeleteError::ProviderIsReadOnly) + )); + } + + #[test] + fn a_missing_file_is_unreadable_not_deleted() { + let dir = home(); + let mut session = codex_session(dir.path()); + std::fs::remove_file(&session.source_path).unwrap(); + session.source_path = session.source_path.clone(); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed( + _, + DeleteError::PathEscapesProviderRoot | DeleteError::ValidationUnreadable + ) + )); + } +} diff --git a/implementations/rust/crates/agent-session-core/src/discovery.rs b/implementations/rust/crates/agent-session-core/src/discovery.rs index e91472e..17ec98a 100644 --- a/implementations/rust/crates/agent-session-core/src/discovery.rs +++ b/implementations/rust/crates/agent-session-core/src/discovery.rs @@ -615,6 +615,36 @@ fn strip_codex_ide_envelope(text: &str) -> String { text.to_string() } +/// The id a Codex rollout names right now: the first `session_meta` / +/// `id` in its head, else the uuid its filename ends with. What +/// [`crate::deletion`] re-parses before removing the file. +pub(crate) fn codex_session_id(path: &Path) -> Option { + let head = jsonl::head_json_lines(path, 10).ok()?; + let from_head = head.iter().find_map(|line| { + let payload = line.get("payload")?; + nonempty_json_string(payload.get("id")) + }); + from_head.or_else(|| session_id_from_rollout_name(path)) +} + +/// The id a Claude session file names right now: a uuid stem, else the +/// last `sessionId` its tail carries, else the first in its head. +pub(crate) fn claude_session_id(path: &Path) -> Option { + let stem = path.file_stem()?.to_string_lossy().into_owned(); + if looks_like_uuid(&stem) { + return Some(stem); + } + let tail = jsonl::tail_json_lines(path, jsonl::MAX_TAIL_BYTES).ok()?; + tail.iter() + .rev() + .find_map(|line| nonempty_json_string(line.get("sessionId"))) + .or_else(|| { + let head = jsonl::head_json_lines(path, 12).ok()?; + head.iter() + .find_map(|line| nonempty_json_string(line.get("sessionId"))) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/implementations/rust/crates/agent-session-core/src/lib.rs b/implementations/rust/crates/agent-session-core/src/lib.rs index b30bd19..31c472c 100644 --- a/implementations/rust/crates/agent-session-core/src/lib.rs +++ b/implementations/rust/crates/agent-session-core/src/lib.rs @@ -8,9 +8,12 @@ //! //! Everything takes explicit paths — like the Swift package, this crate never //! invents a location under someone else's home directory, opens no sockets, -//! and spawns no processes. It also never writes: index mutation stays with -//! the index owner (today the Swift implementation). +//! and spawns no processes. It never edits a file and never touches the +//! index: index mutation stays with the index owner (today the Swift +//! implementation). The one thing it removes is a whole session's own log +//! files, through [`deletion`], fenced exactly as the Swift `SessionDeleter`. +pub mod deletion; pub mod discovery; pub mod error; pub mod index; From 80a4dd771a1d3d728337e668f8c32768fd6cfe13 Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:57:58 +0800 Subject: [PATCH 2/5] fix(rust): deletion re-parses metadata only and refuses symlinked root components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roots are walked component by component with a symlinked component refused, then canonicalized. A Codex file must carry session metadata in its head — the filename's uuid no longer stands in for it — and a name that disagrees with the metadata is refused. A Claude file must parse before a uuid stem is accepted, and a recorded sessionId must agree with it. Grok and Gemini, deletable in the Swift lane, are refused here as not implemented rather than planned for and then failed. Co-Authored-By: Claude Fable 5.1 --- .../crates/agent-session-core/src/deletion.rs | 114 ++++++++++++++++-- .../agent-session-core/src/discovery.rs | 43 ++++--- 2 files changed, 133 insertions(+), 24 deletions(-) diff --git a/implementations/rust/crates/agent-session-core/src/deletion.rs b/implementations/rust/crates/agent-session-core/src/deletion.rs index bad6a63..22dcad4 100644 --- a/implementations/rust/crates/agent-session-core/src/deletion.rs +++ b/implementations/rust/crates/agent-session-core/src/deletion.rs @@ -38,6 +38,8 @@ pub struct DeletionPlan { pub enum DeleteError { #[error("this provider's sessions are read by another app and are never deleted here")] ProviderIsReadOnly, + #[error("this implementation deletes only Codex and Claude Code sessions")] + ProviderNotImplemented, #[error("the session's files do not resolve below one of the provider's roots")] PathEscapesProviderRoot, #[error("one of the session's files is a symbolic link")] @@ -56,10 +58,20 @@ pub enum DeleteOutcome { Failed(SessionToDelete, DeleteError), } +/// Whether this implementation knows how to delete the provider's sessions: +/// it has roots and a re-parse for Codex and Claude Code only. Grok and +/// Gemini are deletable in the Swift lane; here they are refused rather +/// than planned for and then failed. +pub fn implemented_here(provider: SessionProvider) -> bool { + matches!(provider, SessionProvider::Codex | SessionProvider::Claude) +} + /// The provider roots deletion is fenced to, under `home`: the same -/// directories discovery reads, resolved through symlinks so the fence and -/// the targets are compared on equal terms. A root that does not exist is -/// simply absent. +/// directories discovery reads, walked component by component with a +/// symlinked component refused — a link at `.codex` or `sessions` would +/// otherwise make somewhere outside the home an "authorized" root — and +/// then canonicalized so the fence and the targets compare on equal terms. +/// A root that does not exist is simply absent. pub fn provider_roots(provider: SessionProvider, home: &Path) -> Vec { let candidates: &[&[&str]] = match provider { SessionProvider::Codex => &[&[".codex", "sessions"], &[".codex", "archived_sessions"]], @@ -68,13 +80,8 @@ pub fn provider_roots(provider: SessionProvider, home: &Path) -> Vec { }; candidates .iter() - .filter_map(|components| { - let mut path = home.to_path_buf(); - for component in components.iter() { - path.push(component); - } - std::fs::canonicalize(path).ok() - }) + .filter_map(|components| discovery::safe_root(home, components)) + .filter_map(|root| std::fs::canonicalize(root).ok()) .collect() } @@ -86,6 +93,9 @@ pub fn plan(session: &SessionToDelete) -> Result { if !session.provider.supports_deletion() { return Err(DeleteError::ProviderIsReadOnly); } + if !implemented_here(session.provider) { + return Err(DeleteError::ProviderNotImplemented); + } let mut paths = vec![session.source_path.clone()]; if session.provider == SessionProvider::Claude { let sidecar = session.source_path.with_extension(""); @@ -336,6 +346,90 @@ mod tests { )); } + #[test] + fn refuses_providers_the_swift_lane_deletes_but_this_one_does_not() { + let dir = home(); + let session = SessionToDelete { + provider: SessionProvider::Gemini, + session_id: "x".into(), + source_path: dir.path().join("x"), + }; + assert_eq!(plan(&session), Err(DeleteError::ProviderNotImplemented)); + } + + #[test] + fn an_empty_claude_file_with_a_uuid_name_is_unreadable_not_deleted() { + let dir = home(); + let session = claude_session(dir.path()); + std::fs::write(&session.source_path, "").unwrap(); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::ValidationUnreadable) + )); + assert!(session.source_path.exists()); + } + + #[test] + fn a_codex_file_without_metadata_is_unreadable_whatever_its_name_says() { + let dir = home(); + let session = codex_session(dir.path()); + write( + &session.source_path, + &["{\"type\":\"event_msg\",\"payload\":{\"text\":\"no meta here\"}}"], + ); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::ValidationUnreadable) + )); + assert!(session.source_path.exists()); + } + + #[test] + fn a_codex_file_whose_metadata_disagrees_with_its_name_is_refused() { + let dir = home(); + let session = codex_session(dir.path()); + write(&session.source_path, &["{\"type\":\"session_meta\",\"payload\":{\"id\":\"ffffffff-0000-4000-8000-000000000000\"}}"]); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::ValidationUnreadable) + )); + assert!(session.source_path.exists()); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_root_component_authorizes_nothing() { + let dir = home(); + let outside = tempfile::tempdir().unwrap(); + // `/.codex` is a link to a directory outside the home. + std::os::unix::fs::symlink(outside.path(), dir.path().join(".codex")).unwrap(); + let path = outside + .path() + .join("sessions/2026/09/03") + .join(format!("rollout-2026-09-03T10-00-00-{CODEX_ID}.jsonl")); + write( + &path, + &[&format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{CODEX_ID}\"}}}}" + )], + ); + assert!(provider_roots(SessionProvider::Codex, dir.path()).is_empty()); + let session = SessionToDelete { + provider: SessionProvider::Codex, + session_id: CODEX_ID.into(), + source_path: path.clone(), + }; + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::PathEscapesProviderRoot) + )); + assert!(path.exists()); + } + #[test] fn a_missing_file_is_unreadable_not_deleted() { let dir = home(); diff --git a/implementations/rust/crates/agent-session-core/src/discovery.rs b/implementations/rust/crates/agent-session-core/src/discovery.rs index 17ec98a..d5ded7a 100644 --- a/implementations/rust/crates/agent-session-core/src/discovery.rs +++ b/implementations/rust/crates/agent-session-core/src/discovery.rs @@ -259,7 +259,7 @@ pub fn discover_claude(home: &Path, limit: usize) -> Vec { /// directory. This prevents a configured root or an intermediate component /// from redirecting the walk through a symlink. Like other std-only checks it /// remains best-effort against a same-user replacement race after checking. -fn safe_root(home: &Path, components: &[&str]) -> Option { +pub(crate) fn safe_root(home: &Path, components: &[&str]) -> Option { let mut path = home.to_path_buf(); for component in components { path.push(component); @@ -615,34 +615,49 @@ fn strip_codex_ide_envelope(text: &str) -> String { text.to_string() } -/// The id a Codex rollout names right now: the first `session_meta` / -/// `id` in its head, else the uuid its filename ends with. What -/// [`crate::deletion`] re-parses before removing the file. +/// The id a Codex rollout names right now, read from its metadata alone: +/// the `payload.id` of its head. A file whose head carries no metadata — +/// empty, malformed, or truncated — yields `None`; the filename's uuid is +/// good enough for discovery but not for deciding what to delete. When the +/// filename does carry a uuid and it disagrees with the metadata, that is +/// also `None`: the file is not what its name says it is. pub(crate) fn codex_session_id(path: &Path) -> Option { let head = jsonl::head_json_lines(path, 10).ok()?; let from_head = head.iter().find_map(|line| { let payload = line.get("payload")?; nonempty_json_string(payload.get("id")) - }); - from_head.or_else(|| session_id_from_rollout_name(path)) + })?; + match session_id_from_rollout_name(path) { + Some(named) if !named.eq_ignore_ascii_case(&from_head) => None, + _ => Some(from_head), + } } -/// The id a Claude session file names right now: a uuid stem, else the -/// last `sessionId` its tail carries, else the first in its head. +/// The id a Claude session file names right now. The file must parse — an +/// empty or malformed transcript yields `None` even when its stem is a +/// uuid — and a `sessionId` the records carry must agree with a uuid stem. pub(crate) fn claude_session_id(path: &Path) -> Option { let stem = path.file_stem()?.to_string_lossy().into_owned(); - if looks_like_uuid(&stem) { - return Some(stem); - } + let head = jsonl::head_json_lines(path, 12).ok()?; let tail = jsonl::tail_json_lines(path, jsonl::MAX_TAIL_BYTES).ok()?; - tail.iter() + if head.is_empty() && tail.is_empty() { + return None; + } + let recorded = tail + .iter() .rev() .find_map(|line| nonempty_json_string(line.get("sessionId"))) .or_else(|| { - let head = jsonl::head_json_lines(path, 12).ok()?; head.iter() .find_map(|line| nonempty_json_string(line.get("sessionId"))) - }) + }); + if looks_like_uuid(&stem) { + return match recorded { + Some(recorded) if !recorded.eq_ignore_ascii_case(&stem) => None, + _ => Some(stem), + }; + } + recorded } #[cfg(test)] From e9be8d44254ba9f7c04c09bf75b09a1730a927ac Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:04:42 +0800 Subject: [PATCH 3/5] fix(rust): deletion refuses symlinked ancestors and revalidates Codex metadata as discovery reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containment now walks every component between the matched root and the target, refusing a symlinked directory inside the root. Codex revalidation accepts only a session_meta record's id or thread_id, from the head or the bounded tail — the records discovery itself accepts — and never an id on some other kind of record. Co-Authored-By: Claude Fable 5.1 --- .../crates/agent-session-core/src/deletion.rs | 116 +++++++++++++++++- .../agent-session-core/src/discovery.rs | 23 ++-- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/implementations/rust/crates/agent-session-core/src/deletion.rs b/implementations/rust/crates/agent-session-core/src/deletion.rs index 22dcad4..6bca49d 100644 --- a/implementations/rust/crates/agent-session-core/src/deletion.rs +++ b/implementations/rust/crates/agent-session-core/src/deletion.rs @@ -170,16 +170,53 @@ fn delete_one(home: &Path, session: &SessionToDelete) -> Result<(), DeleteError> Ok(()) } -/// Strictly below one of `roots` after symlink resolution: the root itself +/// Strictly below one of `roots` after symlink resolution — the root itself /// never qualifies, and a link pointing outside the provider's tree resolves -/// out of every root's prefix. +/// out of every root's prefix — and reached without passing through a +/// symlinked directory: every component between the root and the target is +/// checked with `symlink_metadata`, the way discovery walks the tree. A +/// link that resolves elsewhere inside the root would otherwise let +/// revalidation and removal follow a path discovery never took. fn is_contained(path: &Path, roots: &[PathBuf]) -> bool { let Ok(resolved) = std::fs::canonicalize(path) else { return false; }; - roots + let Some(root) = roots .iter() - .any(|root| resolved != *root && resolved.starts_with(root)) + .find(|root| resolved != **root && resolved.starts_with(root)) + else { + return false; + }; + // The given path may be spelled through the un-canonicalized root; walk + // the given spelling from its own prefix that maps onto the root. + let Ok(relative) = resolved.strip_prefix(root) else { + return false; + }; + let mut cursor = root.clone(); + for component in relative.components() { + cursor.push(component); + match std::fs::symlink_metadata(&cursor) { + Ok(meta) if !meta.file_type().is_symlink() => {} + _ => return false, + } + } + // And the path as given, component by component below the root's + // spelling of it, so a link in the given spelling is refused too. + let mut given = PathBuf::new(); + let mut below_root = false; + for component in path.components() { + given.push(component); + if !below_root { + if std::fs::canonicalize(&given).ok().as_deref() == Some(root.as_path()) { + below_root = true; + } + continue; + } + if is_symlink(&given) { + return false; + } + } + true } fn is_symlink(path: &Path) -> bool { @@ -430,6 +467,77 @@ mod tests { assert!(path.exists()); } + #[cfg(unix)] + #[test] + fn a_symlinked_directory_inside_the_root_is_refused() { + let dir = home(); + let real = codex_session(dir.path()); + // `/2026/09/link` → `/2026/09/03`: inside the root, but a link. + let link_dir = dir.path().join(".codex/sessions/2026/09/link"); + std::os::unix::fs::symlink(dir.path().join(".codex/sessions/2026/09/03"), &link_dir) + .unwrap(); + let via_link = SessionToDelete { + source_path: link_dir.join(real.source_path.file_name().unwrap()), + ..real.clone() + }; + let outcomes = delete(dir.path(), std::slice::from_ref(&via_link)); + assert!( + matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::PathEscapesProviderRoot) + ), + "{outcomes:?}" + ); + assert!(real.source_path.exists()); + } + + #[test] + fn revalidates_the_older_thread_id_form_and_a_tail_only_meta() { + let dir = home(); + let session = codex_session(dir.path()); + write( + &session.source_path, + &[&format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"thread_id\":\"{CODEX_ID}\"}}}}" + )], + ); + assert!(matches!( + delete(dir.path(), std::slice::from_ref(&session))[0], + DeleteOutcome::Succeeded(_) + )); + let session = codex_session(dir.path()); + let mut lines: Vec = (0..12) + .map(|i| format!("{{\"type\":\"event_msg\",\"payload\":{{\"n\":{i}}}}}")) + .collect(); + lines.push(format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{CODEX_ID}\"}}}}" + )); + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + write(&session.source_path, &refs); + assert!(matches!( + delete(dir.path(), std::slice::from_ref(&session))[0], + DeleteOutcome::Succeeded(_) + )); + } + + #[test] + fn an_id_on_some_other_record_does_not_revalidate() { + let dir = home(); + let session = codex_session(dir.path()); + write( + &session.source_path, + &[&format!( + "{{\"type\":\"response_item\",\"payload\":{{\"id\":\"{CODEX_ID}\"}}}}" + )], + ); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!(matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::ValidationUnreadable) + )); + assert!(session.source_path.exists()); + } + #[test] fn a_missing_file_is_unreadable_not_deleted() { let dir = home(); diff --git a/implementations/rust/crates/agent-session-core/src/discovery.rs b/implementations/rust/crates/agent-session-core/src/discovery.rs index d5ded7a..9d01cf3 100644 --- a/implementations/rust/crates/agent-session-core/src/discovery.rs +++ b/implementations/rust/crates/agent-session-core/src/discovery.rs @@ -616,20 +616,27 @@ fn strip_codex_ide_envelope(text: &str) -> String { } /// The id a Codex rollout names right now, read from its metadata alone: -/// the `payload.id` of its head. A file whose head carries no metadata — -/// empty, malformed, or truncated — yields `None`; the filename's uuid is -/// good enough for discovery but not for deciding what to delete. When the -/// filename does carry a uuid and it disagrees with the metadata, that is -/// also `None`: the file is not what its name says it is. +/// the `id` (or the older `thread_id`) of a `session_meta` record in its +/// head, else in its bounded tail — the same records discovery accepts. A +/// file with no such record — empty, malformed, truncated, or holding an +/// `id` on some other kind of record — yields `None`; the filename's uuid +/// is good enough for discovery but not for deciding what to delete. When +/// the filename does carry a uuid and it disagrees with the metadata, that +/// is also `None`: the file is not what its name says it is. pub(crate) fn codex_session_id(path: &Path) -> Option { let head = jsonl::head_json_lines(path, 10).ok()?; - let from_head = head.iter().find_map(|line| { + let tail = jsonl::tail_json_lines(path, jsonl::MAX_TAIL_BYTES).ok()?; + let recorded = head.iter().chain(tail.iter()).find_map(|line| { + if line.get("type").and_then(|value| value.as_str()) != Some("session_meta") { + return None; + } let payload = line.get("payload")?; nonempty_json_string(payload.get("id")) + .or_else(|| nonempty_json_string(payload.get("thread_id"))) })?; match session_id_from_rollout_name(path) { - Some(named) if !named.eq_ignore_ascii_case(&from_head) => None, - _ => Some(from_head), + Some(named) if !named.eq_ignore_ascii_case(&recorded) => None, + _ => Some(recorded), } } From d50c375d875b9e9f395ff505a137fc3bbbc85311 Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:22:39 +0800 Subject: [PATCH 4/5] fix(rust): deletion refuses a link aliasing the root and removes sidecars before the session file The component that first names a provider root must be the root itself, not a symlink to it. Removal now takes sidecars first and the session file last, so a failure part-way leaves the file that names the session in place for an honest outcome and a retry. Co-Authored-By: Claude Fable 5.1 --- .../crates/agent-session-core/src/deletion.rs | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/implementations/rust/crates/agent-session-core/src/deletion.rs b/implementations/rust/crates/agent-session-core/src/deletion.rs index 6bca49d..831d5e2 100644 --- a/implementations/rust/crates/agent-session-core/src/deletion.rs +++ b/implementations/rust/crates/agent-session-core/src/deletion.rs @@ -112,8 +112,10 @@ pub fn plan(session: &SessionToDelete) -> Result { } /// Delete each session, one at a time, reporting per session. A failure on -/// one never stops the others, and nothing about a failed session is -/// removed — the checks all run before the first `remove`. +/// one never stops the others. The checks all run before the first +/// `remove`; a removal that fails part-way (a sidecar entry that cannot be +/// removed) leaves the session file itself in place, so a `Failed` outcome +/// still names a session that can be re-parsed and retried. pub fn delete(home: &Path, sessions: &[SessionToDelete]) -> Vec { sessions .iter() @@ -150,7 +152,12 @@ fn delete_one(home: &Path, session: &SessionToDelete) -> Result<(), DeleteError> if !reparsed.eq_ignore_ascii_case(&plan.expected_session_id) { return Err(DeleteError::SessionIdMismatch); } - for path in &plan.paths_to_remove { + // Sidecars first, the session file last: a failure part-way leaves the + // file that names the session in place, so the outcome is honest and a + // retry can still re-parse it. + let mut ordered: Vec<&PathBuf> = plan.paths_to_remove.iter().collect(); + ordered.sort_by_key(|path| **path == plan.validation_source_path); + for path in ordered { let Ok(meta) = std::fs::symlink_metadata(path) else { continue; }; @@ -208,6 +215,12 @@ fn is_contained(path: &Path, roots: &[PathBuf]) -> bool { given.push(component); if !below_root { if std::fs::canonicalize(&given).ok().as_deref() == Some(root.as_path()) { + // The component that names the root must be the root itself, + // not a link aliasing it: retargeting such a link between the + // re-parse and the removal would move the target elsewhere. + if is_symlink(&given) { + return false; + } below_root = true; } continue; @@ -538,6 +551,54 @@ mod tests { assert!(session.source_path.exists()); } + #[cfg(unix)] + #[test] + fn a_symlink_aliasing_the_root_is_refused() { + let dir = home(); + let real = codex_session(dir.path()); + let alias = dir.path().join("alias"); + std::os::unix::fs::symlink(dir.path().join(".codex/sessions"), &alias).unwrap(); + let via_alias = SessionToDelete { + source_path: alias + .join("2026/09/03") + .join(real.source_path.file_name().unwrap()), + ..real.clone() + }; + let outcomes = delete(dir.path(), std::slice::from_ref(&via_alias)); + assert!( + matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::PathEscapesProviderRoot) + ), + "{outcomes:?}" + ); + assert!(real.source_path.exists()); + } + + #[cfg(unix)] + #[test] + fn a_sidecar_that_cannot_be_removed_leaves_the_session_file() { + use std::os::unix::fs::PermissionsExt; + let dir = home(); + let session = claude_session(dir.path()); + let sidecar = session.source_path.with_extension(""); + write(&sidecar.join("agent-1.jsonl"), &["{}"]); + std::fs::set_permissions(&sidecar, std::fs::Permissions::from_mode(0o500)).unwrap(); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + std::fs::set_permissions(&sidecar, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!( + matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::RemovalFailed(_)) + ), + "{outcomes:?}" + ); + assert!( + session.source_path.exists(), + "the session file is still there to retry from" + ); + } + #[test] fn a_missing_file_is_unreadable_not_deleted() { let dir = home(); From d6ea3477ab7a8317bf5a216c191bd8bb5a3bfe88 Mon Sep 17 00:00:00 2001 From: AstroQore <69107895+AstroQore@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:31:30 +0800 Subject: [PATCH 5/5] fix(rust): remove a Claude sidecar flat, without following or reopening anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sidecar is a flat directory of agent logs: its entries are unlinked one by one from the directory listing's own file type — a symlink is removed as a link — and a nested directory is refused rather than walked, so nothing is reopened by pathname after being checked. Co-Authored-By: Claude Fable 5.1 --- .../crates/agent-session-core/src/deletion.rs | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/implementations/rust/crates/agent-session-core/src/deletion.rs b/implementations/rust/crates/agent-session-core/src/deletion.rs index 831d5e2..671a726 100644 --- a/implementations/rust/crates/agent-session-core/src/deletion.rs +++ b/implementations/rust/crates/agent-session-core/src/deletion.rs @@ -162,7 +162,7 @@ fn delete_one(home: &Path, session: &SessionToDelete) -> Result<(), DeleteError> continue; }; let result = if meta.file_type().is_dir() { - remove_dir_without_following_links(path) + remove_sidecar_without_following_links(path) } else { std::fs::remove_file(path) }; @@ -236,18 +236,24 @@ fn is_symlink(path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) } -/// Remove a directory tree without ever following a symlink: a link inside -/// is unlinked, never descended. -fn remove_dir_without_following_links(dir: &Path) -> std::io::Result<()> { +/// Remove a sidecar directory without ever following a symlink and without +/// reopening a path after checking it: a Claude sidecar is a flat directory +/// of `agent-*.jsonl` logs, so its entries are unlinked one by one — +/// `remove_file` on a symlink removes the link, never its target — and a +/// nested directory, which a sidecar never has, is refused rather than +/// walked. Nothing here descends, so a directory swapped for a link +/// between two calls has nothing to redirect. +fn remove_sidecar_without_following_links(dir: &Path) -> std::io::Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; - let path = entry.path(); - let meta = std::fs::symlink_metadata(&path)?; - if meta.file_type().is_dir() { - remove_dir_without_following_links(&path)?; - } else { - std::fs::remove_file(&path)?; + // The entry's own type, from the directory listing, not from a + // second look-up of its pathname. + if entry.file_type()?.is_dir() { + return Err(std::io::Error::other( + "a sidecar with a nested directory is left alone", + )); } + std::fs::remove_file(entry.path())?; } std::fs::remove_dir(dir) } @@ -599,6 +605,47 @@ mod tests { ); } + #[test] + fn a_sidecar_with_a_nested_directory_is_left_alone() { + let dir = home(); + let session = claude_session(dir.path()); + let sidecar = session.source_path.with_extension(""); + write(&sidecar.join("nested/deep.jsonl"), &["{}"]); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!( + matches!( + outcomes[0], + DeleteOutcome::Failed(_, DeleteError::RemovalFailed(_)) + ), + "{outcomes:?}" + ); + assert!(session.source_path.exists()); + assert!(sidecar.join("nested/deep.jsonl").exists()); + } + + #[cfg(unix)] + #[test] + fn a_symlink_inside_the_sidecar_is_unlinked_not_followed() { + let dir = home(); + let outside = tempfile::tempdir().unwrap(); + let target = outside.path().join("keep.jsonl"); + std::fs::write(&target, "{}").unwrap(); + let session = claude_session(dir.path()); + let sidecar = session.source_path.with_extension(""); + std::fs::create_dir_all(&sidecar).unwrap(); + std::os::unix::fs::symlink(&target, sidecar.join("agent-link.jsonl")).unwrap(); + let outcomes = delete(dir.path(), std::slice::from_ref(&session)); + assert!( + matches!(outcomes[0], DeleteOutcome::Succeeded(_)), + "{outcomes:?}" + ); + assert!( + target.exists(), + "the link's target outside the root is untouched" + ); + assert!(!sidecar.exists()); + } + #[test] fn a_missing_file_is_unreadable_not_deleted() { let dir = home();