Hmbown/CodeWhale · error · anyhow::Error

sub-agent state path must not traverse symlinks

Error message

sub-agent state path must not traverse symlinks: {}

What it means

Even after top-level containment passes, the library walks each component of the path relative to the state root with `fs::symlink_metadata`, rejecting the operation if any intermediate component is itself a symlink. This closes the TOCTOU-style hole where an in-root symlink redirects deeper segments outside the state root.

Solutions

  1. Replace the intermediate symlink with a real directory (copy contents back into the state root).
  2. If using dotfile managers (stow, chezmoi, GNU stow layouts), configure them not to symlink inside the state root.
  3. Bind-mount or copy the external directory into the state root instead of linking.
  4. Identify the offending component from the error's displayed path and remove that link.

Example fix

// before: symlinked subdirectory inside state root
ln -s /var/lib/shared state/sessions
// after
mkdir state/sessions && cp /var/lib/shared/* state/sessions/
Defensive patterns

Strategy: validation

Validate before calling

use std::fs;
fn no_symlink_components(root: &std::path::Path, p: &std::path::Path) -> bool {
    let mut cur = root.to_path_buf();
    for comp in p.strip_prefix(root).unwrap_or(p).components() {
        cur.push(comp.as_os_str());
        if fs::symlink_metadata(&cur).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
            return false;
        }
    }
    true
}
// guard: no_symlink_components(&state_root, &path)

Try / catch

match load_state(root, path) {
    Ok(v) => v,
    Err(e) if e.to_string().contains("must not traverse symlinks") => {
        eprintln!("symlinked component in state path: {e}");
        // replace the symlink with a real directory, then retry once
        Default::default()
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Any sub-agent state read/write where a directory component between the state root and the final file is a symlink — e.g. `state/link/session.json` where `link` is a symlink.

Common situations: Users symlink state subdirectories into dotfile-managed or shared locations; container images replace state dirs with symlinks to volumes; backup/restore tooling replaced directories with links.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/5a82ee2297408c84. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/tools/subagent/mod.rs:8899

                    {
                        return Err(anyhow!(
                            "sub-agent state path must stay within state root: {}",
                            path.display()
                        ));
                    }
                }
            };
            (resolved_root, relative)
        }
    };
    let mut current = walk_root;
    for component in relative.components() {
        current.push(component.as_os_str());
        let Ok(metadata) = fs::symlink_metadata(&current) else {
            continue;
        };
        if metadata.file_type().is_symlink() {
            return Err(anyhow!(
                "sub-agent state path must not traverse symlinks: {}",
                current.display()
            ));
        }
    }
    Ok(())
}

/// Maximum bytes read from a sub-agent state or transcript artifact file.
/// Generous for long session histories; anything larger is corrupt.
const MAX_SUBAGENT_STATE_BYTES: u64 = 16 * 1024 * 1024;

fn read_subagent_state_file(state_root: &Path, path: &Path) -> Result<String> {
    let state_root = normalize_subagent_workspace(state_root);
    reject_root_relative_symlinks(&state_root, path)?;
    let metadata = fs::symlink_metadata(path)?;
    let file_type = metadata.file_type();
    if file_type.is_symlink() || !file_type.is_file() {

View on GitHub (pinned to 73e0f67d83)