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.
//! Workspace file tools: `file_read` (read-only), `file_write` and `file_edit`
//! (destructive). All paths are resolved through
//! [`workspace::resolve`](super::workspace::resolve), so they can only ever
//! touch files inside the conversation's workspace. The blocking filesystem
//! work runs on a [`tokio::task::spawn_blocking`] thread so it never stalls the
//! shared async runtime.

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

use polyc_llm::ToolSpec;
use serde_json::{Value, json};

use super::{err, truncate_to_bytes, workspace};

/// Upper bound on bytes returned by `file_read`, so a giant file can't blow the
/// model's context in a single call. The harness applies a second, turn-level
/// cap on top.
const MAX_READ_BYTES: usize = 100_000;

/// Read a required string field from the parsed args object.
fn require_str(args: &Value, key: &str) -> Result<String, String> {
    args.get(key)
        .and_then(Value::as_str)
        .map(str::to_owned)
        .ok_or_else(|| format!("`{key}` (string) is required"))
}

/// Resolve `{path}` from the args against `root`, returning the validated
/// absolute path or an error envelope.
fn resolved_path(root: &Path, args_json: &str) -> Result<(String, PathBuf), String> {
    let args = serde_json::from_str::<Value>(args_json)
        .map_err(|_| "arguments must be a JSON object".to_owned())?;
    let path = require_str(&args, "path")?;
    let resolved = workspace::resolve(root, &path)?;
    Ok((path, resolved))
}

/// `file_read` spec — read a UTF-8 file from the workspace.
#[must_use]
pub(super) fn read_spec() -> ToolSpec {
    ToolSpec::new(
        "file_read",
        "Read a UTF-8 text file from the workspace and return its contents. \
         Paths are relative to the workspace root.",
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "Workspace-relative file path." }
            },
            "required": ["path"],
            "additionalProperties": false
        }),
    )
    .titled("Read a file")
    .read_only()
    // Idempotent: if an operator gates reads, the approval is safe to remember.
    .cacheable_approval()
}

/// `file_write` spec — create or overwrite a workspace file.
#[must_use]
pub(super) fn write_spec() -> ToolSpec {
    ToolSpec::new(
        "file_write",
        "Create or overwrite a UTF-8 text file in the workspace (parent \
         directories are created as needed).",
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "Workspace-relative file path." },
                "content": { "type": "string", "description": "Full file contents to write." }
            },
            "required": ["path", "content"],
            "additionalProperties": false
        }),
    )
    .titled("Write a file")
    .destructive()
}

/// `file_edit` spec — replace an exact substring in a workspace file.
#[must_use]
pub(super) fn edit_spec() -> ToolSpec {
    ToolSpec::new(
        "file_edit",
        "Replace an exact string in a workspace file. By default the old string \
         must occur exactly once; set replace_all to replace every occurrence.",
        json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "Workspace-relative file path." },
                "old_string": { "type": "string", "description": "Exact text to replace." },
                "new_string": { "type": "string", "description": "Replacement text." },
                "replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)." }
            },
            "required": ["path", "old_string", "new_string"],
            "additionalProperties": false
        }),
    )
    .titled("Edit a file")
    .destructive()
}

/// Run a blocking closure on a [`spawn_blocking`](tokio::task::spawn_blocking)
/// thread, mapping a runtime join failure to an error envelope.
async fn blocking<F: FnOnce() -> String + Send + 'static>(f: F) -> String {
    tokio::task::spawn_blocking(f)
        .await
        .unwrap_or_else(|_| err("filesystem task failed"))
}

/// Execute `file_read` against `root`.
pub(super) async fn read(root: &Path, args_json: &str) -> String {
    let (path, resolved) = match resolved_path(root, args_json) {
        Ok(p) => p,
        Err(e) => return err(e),
    };
    blocking(move || match std::fs::read_to_string(&resolved) {
        Ok(content) => {
            let (body, truncated) = truncate_to_bytes(&content, MAX_READ_BYTES);
            json!({ "path": path, "content": body, "truncated": truncated }).to_string()
        }
        Err(e) => err(format!("read `{path}`: {e}")),
    })
    .await
}

/// Execute `file_write` against `root`.
pub(super) async fn write(root: &Path, args_json: &str) -> String {
    let Ok(args) = serde_json::from_str::<Value>(args_json) else {
        return err("arguments must be a JSON object");
    };
    let (path, resolved) = match resolved_path(root, args_json) {
        Ok(p) => p,
        Err(e) => return err(e),
    };
    let content = match require_str(&args, "content") {
        Ok(c) => c,
        Err(e) => return err(e),
    };
    blocking(move || {
        if let Some(parent) = resolved.parent()
            && let Err(e) = std::fs::create_dir_all(parent)
        {
            return err(format!("create parent of `{path}`: {e}"));
        }
        match std::fs::write(&resolved, content.as_bytes()) {
            Ok(()) => json!({ "path": path, "bytes_written": content.len() }).to_string(),
            Err(e) => err(format!("write `{path}`: {e}")),
        }
    })
    .await
}

/// Execute `file_edit` against `root`.
pub(super) async fn edit(root: &Path, args_json: &str) -> String {
    let Ok(args) = serde_json::from_str::<Value>(args_json) else {
        return err("arguments must be a JSON object");
    };
    let (path, resolved) = match resolved_path(root, args_json) {
        Ok(p) => p,
        Err(e) => return err(e),
    };
    let old = match require_str(&args, "old_string") {
        Ok(s) => s,
        Err(e) => return err(e),
    };
    let new = match require_str(&args, "new_string") {
        Ok(s) => s,
        Err(e) => return err(e),
    };
    let replace_all = args
        .get("replace_all")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    blocking(move || {
        let content = match std::fs::read_to_string(&resolved) {
            Ok(c) => c,
            Err(e) => return err(format!("read `{path}`: {e}")),
        };
        let count = content.matches(&old).count();
        if count == 0 {
            return err(format!("`old_string` not found in `{path}`"));
        }
        if count > 1 && !replace_all {
            return err(format!(
                "`old_string` occurs {count} times in `{path}`; pass replace_all=true or \
                 provide a more specific string"
            ));
        }
        let updated = if replace_all {
            content.replace(&old, &new)
        } else {
            content.replacen(&old, &new, 1)
        };
        match std::fs::write(&resolved, updated.as_bytes()) {
            Ok(()) => json!({ "path": path, "replacements": count }).to_string(),
            Err(e) => err(format!("write `{path}`: {e}")),
        }
    })
    .await
}

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

    fn tmp_root() -> std::path::PathBuf {
        super::super::tmp_dir("coding-test")
    }

    #[tokio::test]
    async fn write_then_read_round_trips() {
        let root = tmp_root();
        let w = write(&root, r#"{"path":"a/b.txt","content":"hello"}"#).await;
        assert!(w.contains("bytes_written"), "{w}");
        let r = read(&root, r#"{"path":"a/b.txt"}"#).await;
        let v: Value = serde_json::from_str(&r).unwrap();
        assert_eq!(v["content"], "hello");
        std::fs::remove_dir_all(&root).ok();
    }

    #[tokio::test]
    async fn edit_requires_unique_match_unless_replace_all() {
        let root = tmp_root();
        write(&root, r#"{"path":"f.txt","content":"x x x"}"#).await;
        let one = edit(
            &root,
            r#"{"path":"f.txt","old_string":"x","new_string":"y"}"#,
        )
        .await;
        assert!(one.contains("error"), "ambiguous edit must error: {one}");
        let all = edit(
            &root,
            r#"{"path":"f.txt","old_string":"x","new_string":"y","replace_all":true}"#,
        )
        .await;
        assert!(all.contains("replacements"), "{all}");
        let r = read(&root, r#"{"path":"f.txt"}"#).await;
        assert!(serde_json::from_str::<Value>(&r).unwrap()["content"] == "y y y");
        std::fs::remove_dir_all(&root).ok();
    }

    #[tokio::test]
    async fn paths_outside_workspace_are_rejected() {
        let root = tmp_root();
        let r = read(&root, r#"{"path":"../../../etc/passwd"}"#).await;
        assert!(r.contains("error"), "traversal must be rejected: {r}");
        let w = write(&root, r#"{"path":"/etc/evil","content":"x"}"#).await;
        assert!(w.contains("error"), "absolute path must be rejected: {w}");
        std::fs::remove_dir_all(&root).ok();
    }

    #[tokio::test]
    async fn read_truncates_on_byte_budget() {
        let root = tmp_root();
        let big = "a".repeat(MAX_READ_BYTES + 10);
        write(
            &root,
            &json!({ "path": "big.txt", "content": big }).to_string(),
        )
        .await;
        let r = read(&root, r#"{"path":"big.txt"}"#).await;
        let v: Value = serde_json::from_str(&r).unwrap();
        assert_eq!(v["truncated"], true, "{r:.80}");
        assert!(v["content"].as_str().unwrap().len() <= MAX_READ_BYTES);
        std::fs::remove_dir_all(&root).ok();
    }
}