Hmbown/CodeWhale · error · anyhow::Error

sub-agent state path must stay within state root

Error message

sub-agent state path must stay within state root: {}

What it means

This is the containment check for sub-agent state paths: after resolving symlinks and Windows verbatim/8.3 spellings through the deepest existing ancestor, the resulting path must remain inside the canonical state root. If `path_is_within_state_root` returns false — typically because a symlink, `..` component, or alias spelling escaped the root — the operation is refused. This is a security boundary preventing state reads/writes outside the sandbox.

Solutions

  1. Remove symlinks inside the state root that point outside it and use real directories or bind mounts contained in the root.
  2. Pass paths that are strictly under the configured state root with no `..` components.
  3. Re-point the configured state root to the actual canonical location of your state directory.
  4. Re-create state artifacts inside the current state root instead of linking to old locations.

Example fix

// before: symlink escapes the state root
ln -s /etc codewhale/state/escape
resolve_state_path(root, "state/escape/passwd")
// after: keep state inside the root
mkdir -p codewhale/state/inner
resolve_state_path(root, "state/inner/session.json")
Defensive patterns

Strategy: validation

Validate before calling

use std::path::{Path, Component};
fn lexically_inside(root: &Path, p: &Path) -> bool {
    p.canonicalize().ok()
        .map(|c| c.starts_with(root.canonicalize().unwrap_or_else(|_| root.to_path_buf())))
        .unwrap_or(false)
}
// reject user input containing `..` before it reaches the API

Try / catch

match load_state(root, path) {
    Ok(v) => v,
    Err(e) if e.to_string().contains("must stay within state root") => {
        // path escaped the root: do not retry; fix the path or symlinks
        eprintln!("refusing out-of-root state path: {path:?}");
        Default::default()
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Calling a sub-agent state API with a path that, after `resolve_path_for_containment`, does not prefix-match the state root — e.g. a symlink pointing outside the root, or `../` components surviving resolution.

Common situations: Symlinked state directories pointing to a home or tmp location outside the state root; user-supplied paths with traversal segments; migrated state roots where old symlinks now point elsewhere; Windows 8.3 short-name aliases that no longer prefix the root.

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/5486c1195450cc41. Report an issue: GitHub.

Appendix: source

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

    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        state_root.join(path)
    };
    let file_name = absolute
        .file_name()
        .ok_or_else(|| anyhow!("sub-agent state path must include a file name"))?;
    let parent = absolute
        .parent()
        .ok_or_else(|| anyhow!("sub-agent state path must include a parent directory"))?;
    // Resolve through the deepest existing ancestor so Windows short/long names
    // and verbatim (`\\?\`) spellings match the already-canonical state root.
    // A missing parent used to fall back to lexical normalization and could keep
    // an 8.3 component (for example `RUNNER~1`) that no longer prefixes the root.
    let parent = resolve_path_for_containment(parent)?;
    let state_path = parent.join(file_name);
    if !path_is_within_state_root(&state_path, &state_root) {
        return Err(anyhow!(
            "sub-agent state path must stay within state root: {}",
            state_path.display()
        ));
    }
    reject_root_relative_symlinks(&state_root, &state_path)?;
    Ok(state_path)
}

/// Canonicalize `path` when it exists; otherwise canonicalize the deepest
/// existing ancestor and re-append the missing tail. Keeps containment checks
/// on the same identity spelling as [`normalize_subagent_workspace`].
fn resolve_path_for_containment(path: &Path) -> Result<PathBuf> {
    let mut missing = Vec::new();
    let mut current = path.to_path_buf();
    loop {
        match current.canonicalize() {
            Ok(canonical) => {
                let mut resolved = canonical;

View on GitHub (pinned to 73e0f67d83)