polyc-tools 2026.8.3

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! Workspace root, path containment, and sandbox mode for the coding tools.
//!
//! Every coding tool operates relative to a single per-conversation workspace
//! directory (the gVisor harness pod's `/workspace`). [`resolve`] is the one
//! choke point that turns a model-supplied relative path into an absolute path
//! *proven to stay inside* that root — the file tools must never read or write
//! outside it.
//!
//! # Worker fencing (`#2286`)
//!
//! A delegated worker's nested turn used to run every coding tool
//! (`shell_exec`, `file_read`, `file_write`, `file_edit`, `glob`, `grep`)
//! against this SAME root as its parent — the tool executor is built once per
//! turn and shared by every worker, so two concurrent workers writing the
//! same relative path raced on the same file. [`worker_root`] gives each
//! delegated worker its OWN subtree, keyed by its delegate call id: every
//! coding tool re-roots to it, with no read/write split — a worker sees an
//! empty scratch directory and cannot read the parent's (or a sibling
//! worker's) files. This is a full re-root, deliberately, not a narrower
//! write-only fence.
//!
//! The re-root is purely a workspace-relative-path convention layered on top
//! of [`resolve`]'s lexical containment — it is NOT a filesystem or process
//! boundary. `shell_exec` sets the worker root as both the child's working
//! directory and its `HOME`, but a command that runs `cd .. && ...` still
//! reaches everything else in the pod; the real isolation boundary is the
//! gVisor sandbox the whole pod runs under, not this directory convention.
//! Nothing cleans a worker's subtree up after its turn ends — `/workspace` is
//! already a pod-lifetime `emptyDir` nothing cleans between turns, so a
//! persisted worker directory is not a new leak, and keeping it named after
//! the delegate call id lets a human correlate it with that call's forensic
//! `subagent_spawn`/`subagent_result` events.

use std::hash::{Hash, Hasher};
use std::path::{Component, Path, PathBuf};

/// Env var pointing at the per-conversation workspace directory.
pub const WORKSPACE_DIR_ENV: &str = "POLYCHROME_WORKSPACE_DIR";

/// Default workspace root when [`WORKSPACE_DIR_ENV`] is unset.
const DEFAULT_WORKSPACE_DIR: &str = "/workspace";

/// Env var selecting the [`SandboxMode`] (see [`SandboxMode::from_env`]).
pub const SANDBOX_MODE_ENV: &str = "POLYCHROME_SANDBOX_MODE";

/// How freely the coding tools may mutate the workspace before a human must
/// approve — a read-only / workspace-write / full-access gradient.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
    /// Read-only: every destructive coding tool (`shell_exec`, `file_write`,
    /// `file_edit`) is routed through the HITL approval gate.
    ReadOnly,
    /// Workspace-write (the default): destructive coding tools run without
    /// approval because they are confined to the workspace; everything outside
    /// the workspace is still blocked by [`resolve`] / sandbox egress policy.
    #[default]
    WorkspaceWrite,
    /// Full access: nothing is auto-gated (the operator owns the boundary).
    DangerFullAccess,
}

impl SandboxMode {
    /// Resolve the active mode from [`SANDBOX_MODE_ENV`]; unset/unknown →
    /// [`SandboxMode::WorkspaceWrite`] (the coding-agent default).
    #[must_use]
    pub fn from_env() -> Self {
        match std::env::var(SANDBOX_MODE_ENV).ok().as_deref() {
            Some("read-only") => Self::ReadOnly,
            Some("danger-full-access") => Self::DangerFullAccess,
            _ => Self::WorkspaceWrite,
        }
    }

    /// The canonical string for this mode — the inverse of the [`from_env`]
    /// match. Used to bind a session approval to the mode it was granted under.
    ///
    /// [`from_env`]: Self::from_env
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::ReadOnly => "read-only",
            Self::WorkspaceWrite => "workspace-write",
            Self::DangerFullAccess => "danger-full-access",
        }
    }
}

/// The active sandbox mode as its canonical string, for binding a session
/// ("don't ask again") approval to the mode it was granted under.
///
/// Resolves through [`SandboxMode::from_env`] so the bound value is the mode
/// that is actually ENFORCED — unset/unknown canonicalizes to `workspace-write`
/// rather than the raw `""`/typo, so the binding can't silently diverge from
/// enforcement. The single source both the harness (stamp + gate) and the
/// control plane (in-process stamp + gate) read, so the two can't drift.
#[must_use]
pub fn current_sandbox_mode() -> String {
    SandboxMode::from_env().as_str().to_owned()
}

/// The workspace root for this process, from [`WORKSPACE_DIR_ENV`] (default
/// `DEFAULT_WORKSPACE_DIR`).
#[must_use]
pub fn root() -> PathBuf {
    std::env::var(WORKSPACE_DIR_ENV)
        .ok()
        .filter(|s| !s.is_empty())
        .map_or_else(|| PathBuf::from(DEFAULT_WORKSPACE_DIR), PathBuf::from)
}

/// Resolve a model-supplied `rel` path against `root`, guaranteeing the result
/// stays inside `root`.
///
/// Rejects absolute paths and any `..` / root / prefix component, then joins
/// the surviving normal components onto `root`. Purely lexical — it does not
/// touch the filesystem, so it works for not-yet-created files (writes) — and
/// rejects traversal regardless of whether the target exists.
///
/// # Errors
/// Returns a human-readable reason when `rel` is absolute or tries to escape
/// the workspace via `..`.
pub fn resolve(root: &Path, rel: &str) -> Result<PathBuf, String> {
    let candidate = Path::new(rel);
    let mut out = root.to_path_buf();
    for component in candidate.components() {
        match component {
            Component::Normal(part) => out.push(part),
            Component::CurDir => {}
            Component::ParentDir => {
                return Err(format!(
                    "path `{rel}` escapes the workspace (`..` is not allowed)"
                ));
            }
            Component::RootDir | Component::Prefix(_) => {
                return Err(format!(
                    "path `{rel}` must be relative to the workspace (no leading `/`)"
                ));
            }
        }
    }
    Ok(out)
}

/// Prefix for a delegated worker's re-rooted subdirectory name (`#2286`).
///
/// Chosen so it can never collide with an ordinary model-created path: no
/// coding-tool spec ever asks the model to create a path starting with
/// `.worker-`. The leading dot is convention only — this crate's own
/// `search::walk` skips `.git` and nothing else, so the prefix is what that
/// walker matches on to keep a parent's `glob`/`grep` out of its workers'
/// scratch space.
pub(super) const WORKER_SUBDIR_PREFIX: &str = ".worker-";

/// Turn a delegate call id into ONE safe, collision-resistant path component
/// (`#2286`).
///
/// `call_id` is provider-assigned and therefore attacker-influenceable — a
/// model-controlled `__delegate_to` call shapes it indirectly, so this never
/// trusts it as a path component outright. The raw id is filtered to a strict
/// `[A-Za-z0-9._-]` charset (anything else becomes `_`), then a short hash of
/// the RAW (unfiltered) id is appended so that two distinct ids which
/// sanitize to the same string — e.g. `"a/b"` and `"a_b"` — can never
/// collapse into the same worker directory. The result is prefixed with
/// a fixed `.worker-` prefix and is never empty, `.`, or `..`.
#[must_use]
pub fn worker_subdir(call_id: &str) -> String {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    call_id.hash(&mut hasher);
    let hash = hasher.finish();

    let mut sanitized = String::with_capacity(call_id.len());
    for ch in call_id.chars() {
        if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
            sanitized.push(ch);
        } else {
            sanitized.push('_');
        }
    }
    // A sanitized id that is empty, or purely `.`/`..` repeated (e.g. the raw
    // id was "" or ".." or "...."), would otherwise produce a subdir name
    // that is just the hash suffix — still safe (never empty/./..) but this
    // keeps the common case readable and the edge case explicit rather than
    // accidental.
    let sanitized = if sanitized.is_empty() || sanitized.chars().all(|c| c == '.') {
        "call".to_owned()
    } else {
        sanitized
    };

    format!("{WORKER_SUBDIR_PREFIX}{sanitized}-{hash:08x}")
}

/// The dedicated workspace subtree for a delegated worker (`#2286`).
///
/// Joins [`worker_subdir`]'s sanitized, collision-resistant name onto
/// `conversation_root`. Since [`worker_subdir`] always returns a single
/// `Normal` path component, joining it can only ever narrow reach — never
/// widen it back out past `conversation_root`, matching [`resolve`]'s own
/// purely lexical containment.
#[must_use]
pub fn worker_root(conversation_root: &Path, call_id: &str) -> PathBuf {
    conversation_root.join(worker_subdir(call_id))
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;

    #[test]
    fn resolve_keeps_paths_inside_root() {
        let root = Path::new("/workspace");
        assert_eq!(
            resolve(root, "src/main.rs").unwrap(),
            Path::new("/workspace/src/main.rs")
        );
        assert_eq!(
            resolve(root, "./a/./b").unwrap(),
            Path::new("/workspace/a/b")
        );
    }

    #[test]
    fn resolve_rejects_traversal_and_absolute() {
        let root = Path::new("/workspace");
        assert!(resolve(root, "../etc/passwd").is_err());
        assert!(resolve(root, "a/../../b").is_err());
        assert!(resolve(root, "/etc/passwd").is_err());
    }

    #[test]
    fn sandbox_mode_default_is_workspace_write() {
        assert_eq!(SandboxMode::default(), SandboxMode::WorkspaceWrite);
    }

    #[test]
    fn worker_subdir_sanitizes_traversal_ish_ids() {
        for id in ["../../etc", "/abs", "", ".", ".."] {
            let dir = worker_subdir(id);
            assert!(dir.starts_with(WORKER_SUBDIR_PREFIX), "{id:?} -> {dir}");
            assert_ne!(dir, "", "{id:?}");
            assert_ne!(dir, ".", "{id:?}");
            assert_ne!(dir, "..", "{id:?}");
            // Every char is drawn from the strict charset (plus the prefix's
            // own leading dot and the hash's hex digits).
            assert!(
                dir.chars()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
                "{id:?} -> {dir}"
            );
        }
    }

    #[test]
    fn worker_subdir_disambiguates_ids_that_sanitize_identically() {
        // "a/b" and "a_b" both sanitize their non-charset byte to `_`, so
        // without the raw-id hash they would collapse into the same worker
        // directory — the hash suffix must keep them apart.
        let a = worker_subdir("a/b");
        let b = worker_subdir("a_b");
        assert_ne!(a, b, "distinct raw ids must never collapse into one dir");
    }

    #[test]
    fn worker_subdir_is_deterministic_for_the_same_id() {
        assert_eq!(worker_subdir("call-123"), worker_subdir("call-123"));
    }

    #[test]
    fn worker_root_resolve_stays_inside_both_roots() {
        let conversation_root = Path::new("/workspace");
        for id in ["call-a", "../../etc", "", ".."] {
            let root = worker_root(conversation_root, id);
            assert!(
                root.starts_with(conversation_root),
                "worker root for {id:?} must stay inside the conversation root: {root:?}"
            );
            // A benign relative path resolves inside BOTH the worker root and
            // the conversation root it is nested under.
            let resolved = resolve(&root, "src/main.rs").unwrap();
            assert!(resolved.starts_with(&root));
            assert!(resolved.starts_with(conversation_root));
            // Traversal still fails from inside a worker root — re-rooting
            // does not relax `resolve`'s own lexical containment.
            assert!(resolve(&root, "../../etc/passwd").is_err());
            assert!(resolve(&root, "/etc/passwd").is_err());
        }
    }
}