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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
options without echoing attacker-controlled option payloads.

- Add an exact-allowlist generated-cache auditor that defaults to dry-run, blocks live processes,
fingerprints cache-internal symbolic links without following them, and continues to reject an
allowlisted root that is itself a symbolic link.
tool locks, registered or dirty temporary Git workspaces, and provider/Photos/VM boundaries, and
requires a fingerprint-bound approval plus a crash-recoverable private JSON Lines receipt before
removal. Add a plan-first CLI; temporary Git workspaces remain audit-only and route to the
Expand Down
11 changes: 11 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,17 @@ runner's private workspace temp root instead of weakening the shared production
the approved cloud items remain intact and locally materialized until that native path passes
post-allocation verification.

## 2026-08-30 generated-cache symbolic-link boundary

- Live execution-readiness audits found that the allowlisted uv and Playwright caches contain
symbolic-link entries. The prior all-or-nothing rejection prevented DiskSage from producing an
approval fingerprint, allocated-byte estimate, or activity blockers for these ordinary caches.
- The manifest now fingerprints a link's own target text without resolving, opening, or traversing
its destination. An allowlisted root that is itself a symbolic link remains denied, special files
remain denied, and fresh evidence plus exact human approval are still mandatory before removal.
- Focused regression tests prove that changing content outside the cache through a child link does
not alter the cache manifest and that a root link cannot enter the reclaim contract.

## 2026-08-29 temporary-workspace generated-cache recovery

- Project-local Python 3.14 `.venv314` environments now share the same manifest, active-use, journal, and permanent-reclaim checks as `.venv`.
Expand Down
61 changes: 56 additions & 5 deletions src-tauri/src/generated_cache_reclaim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,14 @@ fn observe_tree_inner(path: &Path) -> Result<TreeObservation, String> {
}
let metadata = std::fs::symlink_metadata(&current)
.map_err(|_| "generated-cache-metadata-unavailable".to_string())?;
if metadata.file_type().is_symlink() {
return Err("generated-cache-symlink-rejected".into());
let is_symlink = metadata.file_type().is_symlink();
// The allowlisted cache root itself must remain a real directory. Symlinks below that
// root are ordinary cache entries: fingerprint the link text without resolving or
// traversing it, so a link can never extend the deletion boundary.
if current == path && is_symlink {
return Err("generated-cache-root-symlink-rejected".into());
Comment thread
seonghobae marked this conversation as resolved.
}
if !metadata.is_dir() && !metadata.is_file() {
if !metadata.is_dir() && !metadata.is_file() && !is_symlink {
return Err("generated-cache-special-file-rejected".into());
}
entries += 1;
Expand All @@ -321,7 +325,9 @@ fn observe_tree_inner(path: &Path) -> Result<TreeObservation, String> {
let relative_bytes = path_bytes(relative.as_os_str());
hasher.update(&(relative_bytes.len() as u64).to_le_bytes());
hasher.update(&relative_bytes);
hasher.update(if metadata.is_dir() {
hasher.update(if is_symlink {
b"symlink"
} else if metadata.is_dir() {
b"directory"
} else {
b"file"
Expand All @@ -331,7 +337,13 @@ fn observe_tree_inner(path: &Path) -> Result<TreeObservation, String> {
if current.file_name().is_some_and(|name| name == ".lock") {
locks.push(current.to_string_lossy().into_owned());
}
if metadata.is_file() {
if is_symlink {
let target = std::fs::read_link(&current)
.map_err(|_| "generated-cache-symlink-unreadable".to_string())?;
let target_bytes = path_bytes(target.as_os_str());
hasher.update(&(target_bytes.len() as u64).to_le_bytes());
hasher.update(&target_bytes);
} else if metadata.is_file() {
estimated_content_bytes = estimated_content_bytes
.checked_add(metadata.len())
.ok_or("generated-cache-content-bound-exceeded")?;
Expand Down Expand Up @@ -802,6 +814,8 @@ where
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::symlink;

fn inactive() -> GeneratedCacheActivityEvidence {
GeneratedCacheActivityEvidence {
Expand Down Expand Up @@ -884,6 +898,43 @@ mod tests {
);
}

#[cfg(unix)]
#[test]
fn cache_child_symlink_is_fingerprinted_without_following_target() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path();
let root = home.join(".cache/uv");
let outside = temp.path().join("customer-data");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(&outside, b"must not be read or traversed").unwrap();
symlink(&outside, root.join("cached-link")).unwrap();

let before = plan_with_evidence(&root, home, inactive(), 1).unwrap();
std::fs::write(&outside, b"changed outside content").unwrap();
let after = plan_with_evidence(&root, home, inactive(), 2).unwrap();

assert_eq!(before.content_fingerprint, after.content_fingerprint);
assert_eq!(before.allocated_bytes, after.allocated_bytes);
assert_eq!(before.entry_count, 2);
}

#[cfg(unix)]
#[test]
fn allowlisted_root_symlink_remains_denied() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path();
let outside = temp.path().join("outside-cache");
std::fs::create_dir_all(&outside).unwrap();
std::fs::create_dir_all(home.join(".cache")).unwrap();
let root = home.join(".cache/uv");
symlink(&outside, &root).unwrap();

assert_eq!(
plan_with_evidence(&root, home, inactive(), 1).unwrap_err(),
"generated-cache-root-symlink-rejected"
);
}

#[test]
fn provider_and_virtual_machine_boundaries_are_never_admitted() {
let home = Path::new("/Users/test");
Expand Down
Loading