fix(workspace): narrow home state exemption to the state directory

Second review pass on the home-workspace exemption. Three small corrections,
all in the boundary the first two commits established.

`workspace_state_path` shared the resolution rule with `workspace_state_dir`
by copying it; both now come from one `resolved_home_state`, so the two cannot
drift into disagreeing about where state lives. The function also asserts its
`relative` argument is a literal relative path. Every caller passes components
it wrote itself, and a `relative` carrying `..` would step outside the state
directory the root exists to protect.

The skills exemption matched only the resolved target of the state directory,
so a user who put `~/.codewhale/skills` itself on another volume lost the
exemption and silently lost those skills. It now matches the literal spelling
under the state directory as well: that directory is the user's own
application state, not repository content, so a link inside it stays exempt
whichever end is linked.

The journal's `root` field no longer claims to be the state directory in every
case; for a non-home workspace it is the workspace.

Verified on Windows with junctions on both sides: a non-home workspace still
refuses `<repo>/.codewhale`, a home workspace still passes
`<home>/.codewhale` and walks the resolved target.

- cargo check -p codewhale-util -p codewhale-skills: passed
- cargo check -p codewhale-engine: passed
- cargo test -p codewhale-util: 41 passed; 0 failed
- cargo test -p codewhale-skills: 174 passed; 0 failed
- cargo test -p codewhale-engine --lib -- tools::workflow::journal
  fleet::profile: 41 passed; 0 failed
- cargo test -p codewhale-engine --lib -- subagent::tests::: 590 passed;
  6 failed, the same six that fail on the parent commit with these changes
  stashed (real-home skill leakage into a tempdir workspace, plus one test
  that overflows the default Windows test stack).
- cargo fmt --all -- --check: passed
This commit is contained in:
Shizuku committed 2026-11-03 22:39:50 +08:00
1 parent 0ce030840b
commit 5ad3d821c7
3 files changed
+50 -20

No files matched your search

+3 -1
View File
@@ -178,7 +178,9 @@ enum WorkflowJournalRecord {
#[derive(Debug)]
struct WorkflowRunJournal {
/// State directory the ledger must stay inside; links below it are refused.
/// See [`WorkflowRunJournal::open`] for which root a workspace gets.
/// A home workspace makes this the resolved `~/.codewhale` target, while
/// every other workspace passes itself here with `.codewhale` still inside
/// the ledger path. See [`WorkflowRunJournal::open`].
root: PathBuf,
ledger_path: PathBuf,
}
+15 -6
View File
@@ -1248,15 +1248,24 @@ fn merge_watched_directories(
let mut seen_files = HashSet::new();
// A home workspace is not a repository, but only its own Codewhale state
// directory is exempt: the user may keep `~/.codewhale` behind a junction,
// while every other directory under home stays workspace content and goes
// on refusing links that escape it.
let home_state = workspace
// or put one of its skills directories on another volume, while every other
// directory under home stays workspace content and goes on refusing links
// that escape it. The state directory is matched by its literal spelling as
// well, so a linked directory *inside* the user's own state root keeps the
// exemption that root exists for.
let home = workspace
.filter(|workspace| codewhale_util::files::workspace_is_user_home(workspace))
.map(codewhale_util::files::workspace_state_dir);
.map(|workspace| {
(
workspace.join(codewhale_paths::CODEWHALE_APP_DIR),
codewhale_util::files::workspace_state_dir(workspace),
)
});
for dir in dirs {
watched.push((dir.clone(), watched_path_stamp(&dir)));
let owned_state_dir = home_state.as_ref().is_some_and(|state| {
fs::canonicalize(&dir).is_ok_and(|resolved| resolved.starts_with(state))
let owned_state_dir = home.as_ref().is_some_and(|(literal, resolved)| {
dir.starts_with(literal)
|| fs::canonicalize(&dir).is_ok_and(|target| target.starts_with(resolved))
});
let confine_to =
workspace.filter(|workspace| dir.starts_with(workspace) && !owned_state_dir);
+32 -13
View File
@@ -44,15 +44,24 @@ fn same_directory(left: &Path, right: &Path) -> bool {
#[must_use]
pub fn workspace_state_dir(workspace: &Path) -> std::path::PathBuf {
let state = workspace.join(codewhale_paths::CODEWHALE_APP_DIR);
if workspace_is_user_home(workspace) {
state.canonicalize().unwrap_or(state)
} else {
state
resolved_home_state(workspace).unwrap_or(state)
}
/// `workspace`'s state directory as its real path, for a home workspace whose
/// `~/.codewhale` resolves. `None` is every other case: another workspace, a
/// missing directory (first run), or a link that cannot be reached right now.
fn resolved_home_state(workspace: &Path) -> Option<std::path::PathBuf> {
if !workspace_is_user_home(workspace) {
return None;
}
workspace
.join(codewhale_paths::CODEWHALE_APP_DIR)
.canonicalize()
.ok()
}
/// Confinement root and state path for `relative` beneath the workspace's
/// Codewhale state directory.
/// Codewhale state directory. The tuple is `(root, path)`.
///
/// The root is the boundary a link walk starts from, and
/// [`reject_linked_path`] never examines the root itself — only what is below
@@ -64,18 +73,23 @@ pub fn workspace_state_dir(workspace: &Path) -> std::path::PathBuf {
/// - Every other workspace keeps itself as the root with `.codewhale` still
/// inside the path, so a repository-supplied link is refused exactly as it
/// was before this helper existed.
///
/// `relative` is joined as given. Every caller passes literal path components
/// it wrote itself; a `relative` carrying `..` or an absolute path would step
/// outside the state directory, so this is asserted for those callers rather
/// than defended against a caller that does not exist.
#[must_use]
pub fn workspace_state_path(
workspace: &Path,
relative: &Path,
) -> (std::path::PathBuf, std::path::PathBuf) {
let state = workspace.join(codewhale_paths::CODEWHALE_APP_DIR);
let within_workspace = || (workspace.to_path_buf(), state.join(relative));
if !workspace_is_user_home(workspace) {
return within_workspace();
}
match state.canonicalize() {
Ok(resolved) => {
debug_assert!(
path_is_confined(relative),
"workspace state path must be a relative path of literal components: {}",
relative.display()
);
match resolved_home_state(workspace) {
Some(resolved) => {
let path = resolved.join(relative);
(resolved, path)
}
@@ -83,7 +97,12 @@ pub fn workspace_state_path(
// workspace itself. A directory that exists but cannot be resolved fails
// closed against the strict root rather than writing a second state
// tree beside the one that cannot be reached.
Err(_) => within_workspace(),
None => (
workspace.to_path_buf(),
workspace
.join(codewhale_paths::CODEWHALE_APP_DIR)
.join(relative),
),
}
}