{"record":{"id":"cfff5f2e973ddcd3","repo":"zeroclaw-labs/zeroclaw","slug":"target-sop-directory-escapes-sops-root","errorCode":null,"errorMessage":"target SOP directory '{}' escapes SOPs root","messagePattern":"target SOP directory '(.+?)' escapes SOPs root","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/sop/procedural_memory.rs","lineNumber":400,"sourceCode":"    ensure_relative_component(&slug)?;\n    let target = sops_root.join(slug);\n    ensure_within_root(sops_root, &target)?;\n    Ok(target)\n}\n\n/// Validate that an already-existing SOP directory (taken from the loaded\n/// `Sop.location`) stays within `sops_root`, rejecting `..` and symlink escapes.\nfn contained_existing_dir(sops_root: &Path, location: &Path) -> Result<PathBuf> {\n    let target = location.to_path_buf();\n    ensure_within_root(sops_root, &target)?;\n    Ok(target)\n}\n\nfn ensure_within_root(sops_root: &Path, target: &Path) -> Result<()> {\n    let root = resolve_existing_ancestor(sops_root)?;\n    let resolved_target = resolve_existing_ancestor(target)?;\n    if !resolved_target.starts_with(&root) {\n        bail!(\n            \"target SOP directory '{}' escapes SOPs root\",\n            target.display()\n        );\n    }\n    Ok(())\n}\n\n/// Canonicalize the nearest existing ancestor of `path` and re-append the\n/// not-yet-created trailing components verbatim. A `..` or symlink that escapes\n/// is caught because the existing portion is canonicalized; a trailing\n/// component is rejected unless it is a plain name.\nfn resolve_existing_ancestor(path: &Path) -> Result<PathBuf> {\n    let mut remainder: Vec<&std::ffi::OsStr> = Vec::new();\n    let mut current = path;\n    loop {\n        if current.exists() {\n            let mut resolved = fs::canonicalize(current)\n                .with_context(|| format!(\"canonicalize '{}'\", current.display()))?;","sourceCodeStart":382,"sourceCodeEnd":418,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/sop/procedural_memory.rs#L382-L418","documentation":"ensure_within_root canonicalizes both the SOPs root and the target directory by resolving existing ancestors, then requires the resolved target to still start with the resolved root. This error means that after symlink resolution the target lives outside the SOPs root - a containment violation. The guard protects apply_proposal from writing SOP files through a symlink that escapes the sops directory.","triggerScenarios":"apply_proposal when the configured sops directory (resolve_sops_dir(install_root, config.sops_dir)) or the computed target dir contains a symlink whose resolution points outside the root - e.g. sops_dir set to a symlinked path, or a symlinked SOP directory inside the sops root pointing elsewhere; also a TOCTOU-style swap of a directory component for a symlink between checks.","commonSituations":"Users 'relocating' the sops folder with a symlink to another disk or a dotfiles-managed directory, container setups where the sops dir is a symlink into a volume mount, or an adversarial filesystem actor planting symlinks.","solutions":["Make the sops directory a real directory: point sops_dir at the actual path (or bind-mount) instead of a symlink, then re-run apply_proposal","Audit the sops root for symlinks: find <sops_root> -type l -ls and remove or replace any that resolve outside the root","If the escape was not intentional, treat it as a security incident - inspect what created the symlink before deleting it"],"exampleFix":"# before\nln -s /mnt/shared/sops ~/.zeroclaw/sops\n\n# after\nsudo mount --bind /mnt/shared/sops ~/.zeroclaw/sops\n# or move the directory and update sops_dir in config to the real path","handlingStrategy":"validation","validationCode":"// Before apply_proposal, assert containment the same way the guard does:\nfn dir_within_root(sops_root: &Path, target: &Path) -> bool {\n    let r = std::fs::canonicalize(sops_root).unwrap_or_else(|_| sops_root.to_path_buf());\n    let t = std::fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf());\n    t.starts_with(&r)\n}","typeGuard":null,"tryCatchPattern":"Err(e) if e.to_string().contains(\"escapes SOPs root\") => {\n    // stop and audit the sops dir for symlinks; never catch-and-continue here\n}","preventionTips":["Keep the sops directory a real directory (no symlinks, no bind tricks on the path components)","Run 'find <sops_root> -type l' as part of deployment sanity checks","Pin sops_dir in config to an absolute real path"],"tags":["sop","path-traversal","symlink","security","procedural-memory"],"backgroundTag":"path-traversal-blocked","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}