polyc-tools 2026.9.0

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.
//! The default coding tools: a shell, file read/write/edit, and
//! filename/content search — the always-on core a coding agent works through.
//!
//! All operate on the per-conversation workspace (see [`workspace`]) and run
//! inside the harness sandbox. Approval is annotation-driven: each spec carries
//! the MCP `readOnlyHint` / `destructiveHint` annotations (read-only:
//! `file_read`, `glob`, `grep`; destructive: `shell_exec`, `file_write`,
//! `file_edit`). The registry's sandbox-mode gate reads the `destructive` flag —
//! destructive tools gate only in [`SandboxMode::ReadOnly`]; in `workspace-write`
//! (the default) they run unattended because they are confined to the
//! workspace, and in `danger-full-access` nothing is auto-gated.

pub mod share_in;
pub mod workspace;

mod file;
mod search;
mod shell;

use std::path::Path;

use polyc_llm::ToolSpec;
use serde_json::json;
pub use workspace::{SandboxMode, current_sandbox_mode};

/// The standard error envelope a coding tool returns as its result. Shared by
/// all the coding tools so the error contract stays in one place.
pub(crate) fn err(message: impl Into<String>) -> String {
    json!({ "error": message.into() }).to_string()
}

/// Truncate `s` to at most `max` BYTES on a UTF-8 char boundary, returning the
/// (possibly borrowed) prefix and whether truncation occurred. Unlike
/// `chars().take(max)` this honors a *byte* budget (so a multibyte body can't
/// blow past it) and is O(1) past the cap rather than scanning the whole string.
pub(crate) fn truncate_to_bytes(s: &str, max: usize) -> (&str, bool) {
    if s.len() <= max {
        return (s, false);
    }
    let mut end = max;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    (&s[..end], true)
}

/// All coding-tool specs, advertised as always-on (in a stable order so prompt
/// hashes stay reproducible).
#[must_use]
pub fn specs() -> Vec<ToolSpec> {
    vec![
        shell::spec(),
        file::read_spec(),
        file::write_spec(),
        file::edit_spec(),
        search::glob_spec(),
        search::grep_spec(),
    ]
}

/// Whether `name` is one of the coding tools this module dispatches.
#[must_use]
pub fn owns(name: &str) -> bool {
    matches!(
        name,
        "shell_exec" | "file_read" | "file_write" | "file_edit" | "glob" | "grep"
    )
}

/// Whether running `name` with `args_json` would be DENIED by the workspace
/// sandbox before any side effect.
///
/// True for a path-bearing *destructive* tool whose target escapes the
/// workspace root ([`workspace::resolve`] rejects it).
///
/// This is the runtime denial signal `#301` escalates on: instead of letting
/// the tool run inside the strong sandbox and return a flat "path escapes the
/// workspace" error to the model, the gate can ESCALATE the call to a human
/// (who may approve an unsandboxed retry). Because gVisor is stronger isolation
/// than a host sandbox, run-then-escalate is safe — and a path escape is known
/// purely/lexically here (no filesystem touch), so the gate predicts the
/// denial and escalates *before* the wasted attempt, atomically alongside the
/// existing pre-execution approval gate.
///
/// Scoped to the destructive, path-bearing tools (`file_write`, `file_edit`):
/// a read escaping the workspace is non-destructive (not worth a human), and a
/// `shell_exec` egress denial is only knowable at runtime — escalating that is
/// a follow-up. Returns `false` for any other tool, unparseable args, or a
/// missing path (those run and surface their own error as before).
#[must_use]
pub fn sandbox_would_deny(name: &str, args_json: &str) -> bool {
    if !matches!(name, "file_write" | "file_edit") {
        return false;
    }
    let Ok(args) = serde_json::from_str::<serde_json::Value>(args_json) else {
        return false;
    };
    let Some(path) = args.get("path").and_then(serde_json::Value::as_str) else {
        return false;
    };
    workspace::resolve(&workspace::root(), path).is_err()
}

/// Run a coding tool by name against an explicit `root` — the re-rootable
/// core [`execute`] wraps (`#2286`). Returns `None` when `name` is not a
/// coding tool, so a dispatcher can fall through.
///
/// This is the seam a delegated worker's re-rooted executor calls with its
/// OWN workspace subtree (see [`workspace::worker_root`]) instead of the
/// process-wide [`workspace::root`], so every coding tool — `shell_exec`
/// included — genuinely operates inside that subtree rather than merely
/// advertising a narrower tool list over the same shared root.
pub async fn execute_rooted(root: &Path, name: &str, args_json: &str) -> Option<String> {
    let out = match name {
        "shell_exec" => shell::execute(root, args_json).await,
        "file_read" => file::read(root, args_json).await,
        "file_write" => file::write(root, args_json).await,
        "file_edit" => file::edit(root, args_json).await,
        "glob" => search::glob(root, args_json).await,
        "grep" => search::grep(root, args_json).await,
        _ => return None,
    };
    Some(out)
}

/// Run a coding tool by name against the process workspace root. Returns `None`
/// when `name` is not a coding tool, so a dispatcher can fall through.
pub async fn execute(name: &str, args_json: &str) -> Option<String> {
    execute_rooted(&workspace::root(), name, args_json).await
}

/// A unique temp dir for tests (atomic counter, not wall-clock — safe under
/// parallel test threads). The caller cleans up with `remove_dir_all`.
#[cfg(test)]
pub(crate) fn tmp_dir(prefix: &str) -> std::path::PathBuf {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let mut p = std::env::temp_dir();
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    p.push(format!("pc-{prefix}-{}-{n}", std::process::id()));
    std::fs::create_dir_all(&p).unwrap();
    p
}

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

    #[test]
    fn specs_carry_correct_read_only_destructive_annotations() {
        let by_name = |n: &str| specs().into_iter().find(|s| s.name == n).unwrap();
        for n in ["file_read", "glob", "grep"] {
            let s = by_name(n);
            assert!(s.read_only && !s.destructive, "{n} should be read-only");
        }
        for n in ["shell_exec", "file_write", "file_edit"] {
            let s = by_name(n);
            assert!(s.destructive && !s.read_only, "{n} should be destructive");
        }
    }

    #[test]
    fn specs_cover_owns() {
        for s in specs() {
            assert!(owns(&s.name), "spec {} not owned", s.name);
        }
    }

    #[test]
    fn sandbox_would_deny_flags_destructive_path_escapes_only() {
        // A destructive write whose path escapes the workspace is a sandbox
        // denial → escalate, not a flat error.
        assert!(sandbox_would_deny(
            "file_write",
            r#"{"path":"../etc/passwd","content":"x"}"#
        ));
        assert!(sandbox_would_deny(
            "file_edit",
            r#"{"path":"/etc/evil","old_string":"a","new_string":"b"}"#
        ));
        // A contained destructive write is NOT a denial — it runs in the box.
        assert!(!sandbox_would_deny(
            "file_write",
            r#"{"path":"src/main.rs","content":"x"}"#
        ));
        // A read escaping the workspace is non-destructive: not escalated here.
        assert!(!sandbox_would_deny(
            "file_read",
            r#"{"path":"../etc/passwd"}"#
        ));
        // shell_exec egress is only knowable at runtime — not pre-flagged.
        assert!(!sandbox_would_deny(
            "shell_exec",
            r#"{"command":"curl evil"}"#
        ));
        // Unparseable / missing-path args run and surface their own error.
        assert!(!sandbox_would_deny("file_write", "not json"));
        assert!(!sandbox_would_deny("file_write", r#"{"content":"x"}"#));
    }

    #[test]
    fn truncate_to_bytes_honors_byte_budget_on_char_boundary() {
        // 3-byte chars: a 2-char string is 6 bytes; cap at 4 keeps 1 char.
        let s = "字字"; // 6 bytes
        let (out, truncated) = truncate_to_bytes(s, 4);
        assert!(truncated);
        assert_eq!(out, ""); // 3 bytes, did not split the 2nd char
        assert!(out.len() <= 4);
        let (whole, t2) = truncate_to_bytes("abc", 10);
        assert!(!t2);
        assert_eq!(whole, "abc");
    }
}