supercode-harness 0.4.4

The optional native Supercode agent and tool harness
Documentation
//! CRITICAL security fix (guarantor audit, traced to
//! `permissions::rules::evaluate_path` @ `crates/harness/src/permissions/rules.rs:240`
//! and `agent::permissions_gate_denial_impl`): the ONE shared home for this
//! crate's path-traversal / symlink-escape safety primitives.
//!
//! # Why this module exists
//! The exact `.git`-clobber bug class [`crate::checkpoint`] fixed for its
//! own restore path (P5-9, commits d37d66e/20962bf) was independently
//! reachable through the DEFAULT `write_file`/`edit_file`/`apply_patch`/
//! `read_file` tools, via `permissions.protected_paths`, because the fix was
//! never propagated: `evaluate_path` glob-matched the RAW model-supplied
//! path string with no normalization at all, so `write_file
//! path="x/../.git/config"` did not literally match a `.git/**`
//! protected-path rule even though it resolves right back onto the real
//! `.git/config`. Root cause (the audit's own words): "NO shared safe-path
//! helper — FOUR separate path-validation impls of differing quality; the
//! correct dual lexical+resolved check lives ONLY in checkpoint." This
//! module is the fix for THAT: [`crate::checkpoint`]'s proven primitives,
//! extracted here and reused by [`crate::checkpoint`] itself (delegating,
//! not duplicating), the permissions gate
//! ([`crate::permissions::rules::evaluate_path_safe`] /
//! `crate::agent::Agent::permissions_gate_denial_impl`), and
//! [`crate::tools`]'s `WorkspaceWrite` sandbox containment
//! (`path_within`).
//!
//! A fourth pre-existing impl, `crate::agent::import_target_is_contained`
//! (the `@`-import containment check, P4b), is NOT migrated here — it
//! solves a narrower, already-existing-file-only problem (a plain
//! `std::fs::canonicalize` on both sides) with its own long-standing test
//! coverage, and migrating it carries real regression risk for no security
//! gain (imports were never part of this bug class: there is no reported
//! bypass against it). Left as-is.
//! // TODO(safe-path consolidation): consider routing
//! // `crate::agent::import_target_is_contained` through
//! // [`resolve_real`]/[`contained`] too, for a not-yet-existing-target
//! // symlink-tail edge case it doesn't currently need (imports only ever
//! // target existing files) — tracked, not required by this fix.
//!
//! # The two views of a path
//! Every caller-supplied path has two distinct "real" forms that can
//! disagree exactly when a path component is a symlink:
//! - the **lexical** form: `..`/`.` collapsed textually
//!   ([`crate::tools::normalize`]), symlinks never consulted;
//! - the **resolved** form: the longest EXISTING ancestor is canonicalized
//!   (following symlinks), then any not-yet-existing tail is re-appended
//!   verbatim ([`resolve_real`]) — this is what lets a `write_file` target
//!   that doesn't exist yet still be checked (`std::fs::canonicalize` alone
//!   errors on a non-existent path).
//!
//! A traversal payload like `x/../.git/config` looks clean LEXICALLY at the
//! raw-string level (no literal `.git/` prefix) but normalizes right back to
//! `.git/config` — the lexical form alone already defeats it. A symlink
//! payload (a pre-existing `foo -> .git`, target `foo/config`) is clean in
//! BOTH the raw string AND the lexical-normalized form (`foo/config`, no
//! `.git/` prefix anywhere) AND still lexically "contained" under root (a
//! symlink doesn't escape the root path string, it just relands elsewhere
//! inside it) — only the RESOLVED form reveals it actually lands on
//! `.git/config`. Any protected-floor / containment check that consults
//! only one of these two forms can be bypassed by the other; every check in
//! this module (and every caller of it) consults BOTH.

use std::path::{Path, PathBuf};

/// Resolve `path` to its canonical, symlink-followed absolute form:
/// lexically collapses `..`/`.` first ([`crate::tools::normalize`] — so a
/// not-yet-created path's literal `..` can't lexically claim containment),
/// then walks up from the normalized path to its longest EXISTING ancestor
/// and canonicalizes THAT (resolving any symlink along the way), then
/// re-appends the not-yet-existing tail components verbatim. Fails (`None`)
/// on any resolution error. THE single resolution routine every containment
/// check in this crate builds on.
pub(crate) fn resolve_real(path: &Path) -> Option<PathBuf> {
    let normalized = crate::tools::normalize(path)?;
    let mut existing = normalized.clone();
    let mut tail: Vec<std::ffi::OsString> = Vec::new();
    loop {
        if existing.exists() {
            break;
        }
        let name = existing.file_name().map(|n| n.to_os_string())?;
        tail.push(name);
        if !existing.pop() {
            return None;
        }
    }
    let real_existing = std::fs::canonicalize(&existing).ok()?;
    let mut real = real_existing;
    for name in tail.into_iter().rev() {
        real.push(name);
    }
    Some(real)
}

/// Symlink- and traversal-safe containment check: is `path` (absolute,
/// possibly not-yet-existing) confined under `root`? First lexically
/// collapses `..`/`.` ([`crate::tools::normalize`] — so a not-yet-created
/// path's literal `..` can't lexically claim containment), then resolves
/// the longest EXISTING ancestor (following any symlink along the way — see
/// [`resolve_real`]). Fails closed (`false`) on any resolution error,
/// including `root` itself failing to canonicalize.
pub(crate) fn contained(root: &Path, path: &Path) -> bool {
    let Ok(real_root) = std::fs::canonicalize(root) else {
        return false;
    };
    let Some(real) = resolve_real(path) else {
        return false;
    };
    real.starts_with(&real_root)
}

/// Lexically normalize `path` (expected to be `root.join(rel)` for some
/// caller-declared `rel`) via [`crate::tools::normalize`], then — if the
/// normalized form is still under `root` — return its project-relative,
/// `/`-separated tail. Callers compute this ONCE per entry and feed the
/// single result to a protected-floor check, so that check sees EXACTLY the
/// same normalized path [`contained`]'s containment check computes.
pub(crate) fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
    let normalized_root = crate::tools::normalize(root)?;
    let normalized_path = crate::tools::normalize(path)?;
    let rel = normalized_path.strip_prefix(&normalized_root).ok()?;
    Some(rel.to_string_lossy().replace('\\', "/"))
}

/// Resolved (symlink-following) counterpart to [`normalized_project_rel`]:
/// canonicalizes `path`'s longest existing ancestor (resolving any symlink
/// along the way — see [`resolve_real`]) and returns the resulting
/// absolute path's tail relative to `root`'s own canonical form, as a
/// `/`-separated string — or `None` if resolution fails, or the resolved
/// path lands outside `root` entirely.
pub(crate) fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
    let real_root = std::fs::canonicalize(root).ok()?;
    let real = resolve_real(path)?;
    let rel = real.strip_prefix(&real_root).ok()?;
    Some(rel.to_string_lossy().replace('\\', "/"))
}

/// Up-front rejection for a caller-declared relative path that could never
/// be a legitimate, honestly-produced project-relative path: an absolute
/// path, or one containing a `..` (`ParentDir`), root, empty, or
/// Windows-prefix component. Returns the refusal reason, or `None` if `rel`
/// is clean. Intended for callers whose input is EXPECTED to always be a
/// clean relative path already (e.g. a checkpoint manifest entry) — NOT for
/// a raw model-supplied tool `path` argument, which may legitimately be
/// absolute (see [`resolve_for_matching`] for that case instead).
pub(crate) fn reject_unsafe_rel_path(rel: &str) -> Option<String> {
    // Reason text intentionally contains "escapes" — every containment
    // refusal in this crate is guarding the same property (never operate
    // outside the intended root), just at different stages, so callers/
    // tests can match on one substring either way.
    if rel.is_empty() {
        return Some("refused: escapes the project root (empty path)".to_string());
    }
    let p = Path::new(rel);
    if p.is_absolute() {
        return Some("refused: escapes the project root (absolute path)".to_string());
    }
    let unsafe_component = p.components().any(|c| {
        matches!(
            c,
            std::path::Component::ParentDir
                | std::path::Component::RootDir
                | std::path::Component::Prefix(_)
        )
    });
    if unsafe_component {
        return Some("refused: escapes the project root (unsafe path component)".to_string());
    }
    None
}

/// Is `rel` (a project-relative, `/`-separated path) a hard floor that must
/// never be written to, regardless of caller-supplied config? `.git` (and
/// everything under it) is unconditional; `extra_globs` (e.g.
/// `Config::permissions_protected_paths`) layers additional patterns on
/// top.
pub(crate) fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
    if rel == ".git" || rel.starts_with(".git/") {
        return true;
    }
    extra_globs
        .iter()
        .any(|g| crate::config::glob_match(g, rel))
}

/// The outcome of [`resolve_for_matching`] — the two safe glob-matching
/// subjects for a raw, caller-supplied `path` tool argument (which, unlike
/// a checkpoint manifest entry, may legitimately be absolute — e.g. a
/// `write_file` call under `SandboxPolicy::DangerFullAccess`), OR a reason
/// it can't be resolved safely.
pub(crate) enum PathForMatching {
    /// `raw` normalizes to somewhere under `root`. `lexical_rel` is the
    /// purely lexical (`..`-collapsed, no symlink resolution) project-
    /// relative form; `resolved_rel` is the symlink-RESOLVED project-
    /// relative form (may equal `lexical_rel` when no component along the
    /// path is a symlink). BOTH must be glob-matched against protected-path
    /// / path-rule patterns — matching only one is exactly the bypass this
    /// module exists to close.
    Inside {
        lexical_rel: String,
        resolved_rel: String,
    },
    /// `raw`, once normalized, lands outside `root` entirely (e.g. a
    /// legitimate absolute path elsewhere on disk, or a `..` that walks
    /// past root) — not inherently hostile; no root-relative protected-path
    /// pattern can apply, since there is no root-relative form to match. A
    /// caller's raw-string / real-tool-name rule matching still applies
    /// independently of this.
    Outside,
    /// Resolution could not be proven safe: `root` or `raw` failed to
    /// resolve, OR `raw` normalizes to somewhere lexically INSIDE `root`
    /// but symlink-resolves to somewhere OUTSIDE it (the exact escape
    /// [`contained`] exists to catch). FAIL CLOSED: a caller must treat
    /// this as at least as strict as the protected floor matching — never
    /// silently skip the check and fall through to an `Allow` default.
    Unsafe(&'static str),
}

/// Resolve a raw, caller-supplied `path` argument (relative to `root` if
/// not already absolute — the same "join onto cwd" contract
/// `crate::tools::ToolContext::resolve` uses) into the two safe
/// glob-matching subjects a permissions-gate protected-path / path-rule
/// check must consult, per this module's doc comment. This is what closes
/// the CRITICAL bug: `write_file path="x/../.git/config"` does not
/// literally glob-match a `.git/**` protected-path rule as a raw string,
/// but `resolve_for_matching` computes its `lexical_rel` (and, since no
/// symlink is involved here, `resolved_rel`) as `.git/config`, which does.
pub(crate) fn resolve_for_matching(root: &Path, raw: &str) -> PathForMatching {
    let candidate = if Path::new(raw).is_absolute() {
        PathBuf::from(raw)
    } else {
        root.join(raw)
    };

    let Some(normalized_root) = crate::tools::normalize(root) else {
        return PathForMatching::Unsafe("root does not resolve");
    };
    let Some(normalized_candidate) = crate::tools::normalize(&candidate) else {
        return PathForMatching::Unsafe("path does not resolve");
    };
    let lexical_rel = match normalized_candidate.strip_prefix(&normalized_root) {
        Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
        Err(_) => return PathForMatching::Outside,
    };

    let Some(real_root) = resolve_real(root) else {
        return PathForMatching::Unsafe("root symlink resolution failed");
    };
    let Some(real_candidate) = resolve_real(&candidate) else {
        return PathForMatching::Unsafe("path symlink resolution failed");
    };
    let resolved_rel = match real_candidate.strip_prefix(&real_root) {
        Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
        Err(_) => {
            // Lexically inside root but resolves (symlinks followed)
            // OUTSIDE it -- exactly the escape `contained` exists to
            // catch. Fail closed rather than silently only matching the
            // lexical form.
            return PathForMatching::Unsafe("resolves outside root (symlink escape)");
        }
    };

    PathForMatching::Inside {
        lexical_rel,
        resolved_rel,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "supercode-safepath-test-{tag}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn resolve_for_matching_collapses_dot_dot_traversal_lexically() {
        let root = tmp("traversal");
        std::fs::create_dir_all(root.join(".git")).unwrap();
        std::fs::write(root.join(".git").join("config"), "real").unwrap();
        match resolve_for_matching(&root, "x/../.git/config") {
            PathForMatching::Inside {
                lexical_rel,
                resolved_rel,
            } => {
                assert_eq!(lexical_rel, ".git/config");
                assert_eq!(resolved_rel, ".git/config");
            }
            _ => panic!("expected Inside"),
        }
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn resolve_for_matching_clean_relative_path_round_trips() {
        let root = tmp("clean");
        std::fs::create_dir_all(root.join("src")).unwrap();
        match resolve_for_matching(&root, "src/main.rs") {
            PathForMatching::Inside {
                lexical_rel,
                resolved_rel,
            } => {
                assert_eq!(lexical_rel, "src/main.rs");
                assert_eq!(resolved_rel, "src/main.rs");
            }
            _ => panic!("expected Inside"),
        }
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    #[cfg(unix)]
    fn resolve_for_matching_symlink_escape_into_dot_git_is_unsafe() {
        let root = tmp("symlink");
        std::fs::create_dir_all(root.join(".git")).unwrap();
        std::fs::write(root.join(".git").join("config"), "real").unwrap();
        std::os::unix::fs::symlink(root.join(".git"), root.join("foo")).unwrap();
        match resolve_for_matching(&root, "foo/config") {
            PathForMatching::Inside {
                lexical_rel,
                resolved_rel,
            } => {
                assert_eq!(lexical_rel, "foo/config");
                assert_eq!(
                    resolved_rel, ".git/config",
                    "the RESOLVED form must reveal the real symlink target"
                );
            }
            _ => panic!("expected Inside with a divergent resolved_rel"),
        }
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn resolve_for_matching_absolute_path_outside_root_is_outside() {
        let root = tmp("outside-root");
        let elsewhere = tmp("outside-elsewhere");
        let outside_path = elsewhere.join("scratch.txt");
        assert!(matches!(
            resolve_for_matching(&root, outside_path.to_str().unwrap()),
            PathForMatching::Outside
        ));
        std::fs::remove_dir_all(&root).ok();
        std::fs::remove_dir_all(&elsewhere).ok();
    }

    #[test]
    fn contained_rejects_symlink_escape() {
        let root = tmp("contained-symlink-root");
        let outside = tmp("contained-symlink-outside");
        std::fs::write(outside.join("secret.txt"), "s").unwrap();
        #[cfg(unix)]
        {
            std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
            assert!(!contained(&root, &root.join("link.txt")));
        }
        std::fs::remove_dir_all(&root).ok();
        std::fs::remove_dir_all(&outside).ok();
    }

    #[test]
    fn contained_accepts_a_brand_new_file_inside_the_root() {
        let root = tmp("contained-newfile");
        assert!(contained(&root, &root.join("does_not_exist_yet.txt")));
        assert!(contained(&root, &root.join("nested/dir/new.txt")));
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn is_protected_hard_floor_covers_dot_git_regardless_of_extra_globs() {
        assert!(is_protected(".git", &[]));
        assert!(is_protected(".git/config", &[]));
        assert!(!is_protected(".gitignore", &[]));
        assert!(!is_protected("src/main.rs", &[]));
    }

    #[test]
    fn reject_unsafe_rel_path_catches_absolute_and_traversal() {
        assert!(reject_unsafe_rel_path("../etc/passwd").is_some());
        assert!(reject_unsafe_rel_path("/etc/passwd").is_some());
        assert!(reject_unsafe_rel_path("").is_some());
        assert!(reject_unsafe_rel_path("src/main.rs").is_none());
    }
}