Skip to main content

speccoder_runtime/tools/
mod.rs

1//! speccoder's tools: what an attempt may do to
2//! its worktree, served to agentplane as tool servers `fs`, `proc` and `git`.
3//!
4//! Domain failures (no such file, ambiguous edit, protected path) come back
5//! as ordinary results `{"ok": false, "error": …}` so the model can see them
6//! and recover; agentplane `ToolError`s are reserved for infrastructure.
7
8pub mod paths;
9pub mod policy;
10
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use agentplane::model::ToolDeclaration;
16use agentplane::tools::{Destination, ToolClient, ToolError, ToolId};
17use async_trait::async_trait;
18use schemars::JsonSchema;
19use serde::Deserialize;
20use serde_json::{Value, json};
21use sha2::{Digest, Sha256};
22
23use self::paths::{Protected, resolve_existing, resolve_for_write};
24use self::policy::{Decision, Policy};
25use crate::git::{Git, nul_fields};
26use crate::verify::process::{Invocation, OUTPUT_CAP, scrubbed_env};
27
28/// `read_file` cap.
29pub const READ_CAP: usize = 256 * 1024;
30/// `list_files` cap.
31pub const LIST_CAP: usize = 500;
32/// `search_code` match cap.
33pub const SEARCH_CAP: usize = 200;
34/// Characters kept per search-result line.
35pub const LINE_CAP: usize = 500;
36/// Longest `run_command` timeout a model may ask for.
37pub const MAX_COMMAND_SECS: u64 = 600;
38
39// ── Inputs (their JSON schemas are what the model sees) ─────────────────────
40
41/// Read a text file in the workspace, optionally a range of lines.
42#[derive(Debug, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct ReadFile {
45    /// Path relative to the workspace root.
46    pub path: String,
47    /// First line to return (1-based).
48    pub start_line: Option<usize>,
49    /// Last line to return (inclusive).
50    pub end_line: Option<usize>,
51}
52
53/// List files (not ignored by .gitignore), optionally under a directory and matching a glob.
54#[derive(Debug, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct ListFiles {
57    /// Directory relative to the workspace root (default: the root).
58    pub path: Option<String>,
59    /// Glob such as `**/*.rs`.
60    pub glob: Option<String>,
61}
62
63/// Search file contents with a regular expression (respects .gitignore).
64#[derive(Debug, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct SearchCode {
67    /// Rust regex syntax.
68    pub pattern: String,
69    /// Directory relative to the workspace root (default: the root).
70    pub path: Option<String>,
71    /// Only files matching this glob.
72    pub glob: Option<String>,
73}
74
75/// Create or replace a whole file.
76#[derive(Debug, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct WriteFile {
79    /// Path relative to the workspace root.
80    pub path: String,
81    /// The complete new contents.
82    pub content: String,
83}
84
85/// Replace an exact string in a file: once (it must occur exactly once) or everywhere.
86#[derive(Debug, Deserialize, JsonSchema)]
87#[serde(deny_unknown_fields)]
88pub struct EditFile {
89    /// Path relative to the workspace root.
90    pub path: String,
91    /// Exact text to find, including whitespace.
92    pub old_string: String,
93    /// Replacement text.
94    pub new_string: String,
95    /// Replace every occurrence instead of exactly one (default: one).
96    pub replace_all: Option<bool>,
97}
98
99/// Run a program (no shell) in the workspace with a timeout; stdin is closed.
100#[derive(Debug, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields)]
102pub struct RunCommand {
103    /// Program and arguments, e.g. ["cargo", "test", "-p", "core"].
104    pub argv: Vec<String>,
105    /// Working directory relative to the workspace root (default: the root).
106    pub cwd: Option<String>,
107    /// Seconds before the whole process group is killed (default 120, at most 600).
108    pub timeout_secs: Option<u64>,
109    /// Commands run without the network; `true` asks for it (a person decides).
110    pub network: Option<bool>,
111    /// Set by speccoder, not the model: the call's id, exported as
112    /// `SPECCODER_CALL_ID`.
113    #[schemars(skip)]
114    #[serde(default)]
115    pub call_id: Option<String>,
116}
117
118/// Show changed, added and untracked files.
119#[derive(Debug, Deserialize, JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct GitStatus {}
122
123/// Show the diff of the working tree against HEAD, optionally for one path.
124#[derive(Debug, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct GitDiff {
127    /// Limit the diff to this path.
128    pub path: Option<String>,
129}
130
131/// One tool as the model and agentplane see it.
132#[derive(Debug, Clone)]
133pub struct ToolSpec {
134    /// Name offered to the model, e.g. `read_file`.
135    pub name: &'static str,
136    /// agentplane tool server: `fs`, `proc` or `git`.
137    pub server: &'static str,
138    /// Whether calling it can change the world.
139    pub mutates: bool,
140    /// Argument pointers the model authors (declared on mutating grants).
141    pub model_fields: &'static [&'static str],
142    /// What the model is told.
143    pub description: &'static str,
144    /// JSON schema of the arguments.
145    pub parameters: fn() -> Value,
146}
147
148/// The schema of a tool's arguments, made strict-ready by agentplane: only
149/// what serde enforces is declared — an optional argument is an `Option`,
150/// so `null` is both allowed and readable.
151fn schema<T: JsonSchema>() -> Value {
152    agentplane::tools::strict_schema::<T>()
153}
154
155/// Every tool, in a stable order (the order is part of the cached prompt prefix).
156#[must_use]
157pub fn catalog() -> Vec<ToolSpec> {
158    vec![
159        ToolSpec {
160            name: "read_file",
161            server: "fs",
162            mutates: false,
163            model_fields: &[],
164            description: "Read a text file in the workspace, optionally a range of lines.",
165            parameters: schema::<ReadFile>,
166        },
167        ToolSpec {
168            name: "list_files",
169            server: "fs",
170            mutates: false,
171            model_fields: &[],
172            description: "List files not ignored by .gitignore, optionally under a directory and matching a glob.",
173            parameters: schema::<ListFiles>,
174        },
175        ToolSpec {
176            name: "search_code",
177            server: "fs",
178            mutates: false,
179            model_fields: &[],
180            description: "Search file contents with a regular expression; returns path:line: text.",
181            parameters: schema::<SearchCode>,
182        },
183        ToolSpec {
184            name: "write_file",
185            server: "fs",
186            mutates: true,
187            model_fields: &["/path", "/content"],
188            description: "Create or replace a whole file. Spec Kit files, CI and test configuration, speccoder.toml and .git are refused.",
189            parameters: schema::<WriteFile>,
190        },
191        ToolSpec {
192            name: "edit_file",
193            server: "fs",
194            mutates: true,
195            model_fields: &["/path", "/old_string", "/new_string"],
196            description: "Replace an exact string in a file: old_string is removed and new_string takes its place (exactly one occurrence unless replace_all). To add text without removing any, anchor on existing text and repeat it: old_string = an existing line, new_string = that same line followed by the new one.",
197            parameters: schema::<EditFile>,
198        },
199        ToolSpec {
200            name: "run_command",
201            server: "proc",
202            mutates: true,
203            model_fields: &["/argv"],
204            description: "Run a program without a shell in the workspace; stdin is closed, output is capped, the timeout kills the process group.",
205            parameters: schema::<RunCommand>,
206        },
207        ToolSpec {
208            name: "git_status",
209            server: "git",
210            mutates: false,
211            model_fields: &[],
212            description: "Show modified, added, deleted and untracked files.",
213            parameters: schema::<GitStatus>,
214        },
215        ToolSpec {
216            name: "git_diff",
217            server: "git",
218            mutates: false,
219            model_fields: &[],
220            description: "Show the working tree's diff against HEAD.",
221            parameters: schema::<GitDiff>,
222        },
223    ]
224}
225
226/// The model-facing declarations, in catalog order.
227#[must_use]
228pub fn declarations() -> Vec<ToolDeclaration> {
229    catalog()
230        .into_iter()
231        .map(|t| ToolDeclaration::new(t.name, t.description, (t.parameters)()))
232        .collect()
233}
234
235// ── The tool server ─────────────────────────────────────────────────────────
236
237/// Tools bound to one worktree.
238#[derive(Debug, Clone)]
239pub struct WorkspaceTools {
240    root: PathBuf,
241    policy: Policy,
242    runner: crate::sandbox::Runner,
243    /// Variables every command gets (a hosted skill's current unit).
244    env: Vec<(String, String)>,
245}
246
247fn failure(error: impl Into<String>) -> Value {
248    json!({ "ok": false, "error": error.into() })
249}
250
251fn sha(bytes: &[u8]) -> String {
252    Sha256::digest(bytes)
253        .iter()
254        .map(|b| format!("{b:02x}"))
255        .collect()
256}
257
258fn parse<T: for<'de> Deserialize<'de>>(args: &Value) -> Result<T, Value> {
259    serde_json::from_value(args.clone()).map_err(|e| failure(format!("invalid arguments: {e}")))
260}
261
262impl WorkspaceTools {
263    /// Tools for the worktree at `root` under `policy`.
264    pub fn new(root: &Path, policy: Policy) -> std::io::Result<Self> {
265        Ok(Self {
266            root: fs::canonicalize(root)?,
267            policy,
268            runner: crate::sandbox::Runner::Plain,
269            env: Vec::new(),
270        })
271    }
272
273    /// Commands also get `env` (the policy's own variables still win).
274    #[must_use]
275    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
276        self.env = env;
277        self
278    }
279
280    /// Runs commands with `runner` (the sandbox).
281    #[must_use]
282    pub fn with_runner(mut self, runner: crate::sandbox::Runner) -> Self {
283        self.runner = runner;
284        self
285    }
286
287    fn protected(&self) -> &Protected {
288        self.policy.protected()
289    }
290
291    /// Runs one tool by name; the result is what the model sees.
292    pub async fn run(&self, name: &str, args: &Value) -> Value {
293        // Denials are enforced here too, whatever the caller decided.
294        if let Decision::Deny(reason) = self.policy.classify(name, args) {
295            return failure(format!("denied: {reason}"));
296        }
297        let out = match name {
298            "read_file" => parse(args).map(|a| self.read_file(&a)),
299            "list_files" => parse(args).map(|a| self.list_files(&a)),
300            "search_code" => parse(args).map(|a| self.search_code(&a)),
301            "write_file" => parse(args).map(|a| self.write_file(&a)),
302            "edit_file" => parse(args).map(|a| self.edit_file(&a)),
303            "run_command" => match parse::<RunCommand>(args) {
304                Ok(a) => Ok(self.run_command(a).await),
305                Err(e) => Err(e),
306            },
307            "git_status" => parse::<GitStatus>(args).map(|_| self.git_status()),
308            "git_diff" => parse(args).map(|a| self.git_diff(&a)),
309            other => Err(failure(format!("unknown tool `{other}`"))),
310        };
311        out.unwrap_or_else(|e| e)
312    }
313
314    fn rel(&self, p: &Path) -> String {
315        p.strip_prefix(&self.root)
316            .unwrap_or(p)
317            .to_string_lossy()
318            .replace('\\', "/")
319    }
320
321    fn read_file(&self, a: &ReadFile) -> Value {
322        let path = match resolve_existing(&self.root, &a.path) {
323            Ok(p) => p,
324            Err(e) => return failure(e.0),
325        };
326        if self.protected().is_secret(&self.rel(&path)) {
327            return failure(format!("`{}` looks like a credential", a.path));
328        }
329        let bytes = match fs::read(&path) {
330            Ok(b) => b,
331            Err(e) => return failure(format!("`{}`: {e}", a.path)),
332        };
333        let Ok(text) = String::from_utf8(bytes.clone()) else {
334            return json!({ "ok": true, "path": a.path, "binary": true, "bytes": bytes.len() });
335        };
336        let lines: Vec<&str> = text.lines().collect();
337        let start = a.start_line.unwrap_or(1).max(1);
338        let end = a.end_line.unwrap_or(lines.len()).min(lines.len());
339        let mut content = if start > end {
340            String::new()
341        } else {
342            lines[start - 1..end].join("\n")
343        };
344        let truncated = content.len() > READ_CAP;
345        if truncated {
346            let mut cut = READ_CAP;
347            while !content.is_char_boundary(cut) {
348                cut -= 1;
349            }
350            content.truncate(cut);
351            content.push_str("\n[… truncated; read a line range …]");
352        }
353        json!({ "ok": true, "path": a.path, "start_line": start, "end_line": end, "total_lines": lines.len(), "truncated": truncated, "content": content })
354    }
355
356    fn walk(&self, dir: Option<&str>, glob: Option<&str>) -> Result<Vec<PathBuf>, Value> {
357        let base = match dir {
358            Some(d) => resolve_existing(&self.root, d).map_err(|e| failure(e.0))?,
359            None => self.root.clone(),
360        };
361        let matcher = glob
362            .map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
363            .transpose()
364            .map_err(|e| failure(format!("invalid glob: {e}")))?;
365        let mut files: Vec<PathBuf> = ignore::WalkBuilder::new(&base)
366            .hidden(false)
367            .filter_entry(|e| e.file_name() != ".git")
368            .build()
369            .flatten()
370            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
371            .map(ignore::DirEntry::into_path)
372            .filter(|p| {
373                let rel = self.rel(p);
374                !self.protected().is_secret(&rel)
375                    && matcher.as_ref().is_none_or(|m| m.is_match(&rel))
376            })
377            .collect();
378        files.sort();
379        Ok(files)
380    }
381
382    fn list_files(&self, a: &ListFiles) -> Value {
383        match self.walk(a.path.as_deref(), a.glob.as_deref()) {
384            Ok(files) => {
385                let total = files.len();
386                let shown: Vec<String> = files.iter().take(LIST_CAP).map(|p| self.rel(p)).collect();
387                json!({ "ok": true, "files": shown, "total": total, "truncated": total > LIST_CAP })
388            }
389            Err(e) => e,
390        }
391    }
392
393    fn search_code(&self, a: &SearchCode) -> Value {
394        let re = match regex::Regex::new(&a.pattern) {
395            Ok(r) => r,
396            Err(e) => return failure(format!("invalid pattern: {e}")),
397        };
398        let files = match self.walk(a.path.as_deref(), a.glob.as_deref()) {
399            Ok(f) => f,
400            Err(e) => return e,
401        };
402        let mut matches = Vec::new();
403        let mut total = 0usize;
404        for f in files {
405            let Ok(bytes) = fs::read(&f) else { continue };
406            if bytes.len() > 1024 * 1024 || bytes.iter().take(8192).any(|b| *b == 0) {
407                continue;
408            }
409            let text = String::from_utf8_lossy(&bytes);
410            for (i, line) in text.lines().enumerate() {
411                if re.is_match(line) {
412                    total += 1;
413                    if matches.len() < SEARCH_CAP {
414                        let shown: String = line.chars().take(LINE_CAP).collect();
415                        matches.push(format!("{}:{}: {shown}", self.rel(&f), i + 1));
416                    }
417                }
418            }
419        }
420        json!({ "ok": true, "matches": matches, "total": total, "truncated": total > SEARCH_CAP })
421    }
422
423    fn write_checked(&self, rel: &str) -> Result<PathBuf, Value> {
424        let path = resolve_for_write(&self.root, rel).map_err(|e| failure(e.0))?;
425        if let Some(class) = self.policy.write_protected(&self.rel(&path)) {
426            return Err(failure(format!(
427                "denied: `{rel}` is protected: {}",
428                self.policy.protected().reason(class)
429            )));
430        }
431        Ok(path)
432    }
433
434    fn write_file(&self, a: &WriteFile) -> Value {
435        let path = match self.write_checked(&a.path) {
436            Ok(p) => p,
437            Err(e) => return e,
438        };
439        if path.is_dir() {
440            return failure(format!("`{}` is a directory", a.path));
441        }
442        let before = fs::read(&path)
443            .ok()
444            .map_or_else(|| "absent".to_owned(), |b| sha(&b));
445        if let Some(parent) = path.parent()
446            && let Err(e) = fs::create_dir_all(parent)
447        {
448            return failure(format!("`{}`: {e}", a.path));
449        }
450        if let Err(e) = fs::write(&path, &a.content) {
451            return failure(format!("`{}`: {e}", a.path));
452        }
453        json!({ "ok": true, "path": a.path, "before": before, "after": sha(a.content.as_bytes()), "bytes": a.content.len() })
454    }
455
456    fn edit_file(&self, a: &EditFile) -> Value {
457        let path = match self.write_checked(&a.path) {
458            Ok(p) => p,
459            Err(e) => return e,
460        };
461        let Ok(text) = fs::read_to_string(&path) else {
462            return failure(format!("`{}` does not exist or is not text", a.path));
463        };
464        if a.old_string.is_empty() {
465            return failure("`old_string` is empty");
466        }
467        let count = text.matches(&a.old_string).count();
468        let replace_all = a.replace_all.unwrap_or(false);
469        let new = match (count, replace_all) {
470            (0, _) => return failure(format!("`old_string` not found in `{}`", a.path)),
471            (1, _) | (_, true) => text.replace(&a.old_string, &a.new_string),
472            (n, false) => {
473                return failure(format!(
474                    "`old_string` occurs {n} times in `{}`; add context to make it unique or set replace_all",
475                    a.path
476                ));
477            }
478        };
479        if let Err(e) = fs::write(&path, &new) {
480            return failure(format!("`{}`: {e}", a.path));
481        }
482        json!({ "ok": true, "path": a.path, "replacements": if replace_all { count } else { 1 }, "before": sha(text.as_bytes()), "after": sha(new.as_bytes()) })
483    }
484
485    async fn run_command(&self, a: RunCommand) -> Value {
486        // `.`, `./` and an empty string mean the workspace root (models pass them).
487        let cwd = match a
488            .cwd
489            .as_deref()
490            .filter(|d| !matches!(d.trim(), "" | "." | "./"))
491        {
492            None => self.root.clone(),
493            Some(d) => match resolve_existing(&self.root, d) {
494                Ok(p) if p.is_dir() => p,
495                Ok(_) => return failure(format!("`{d}` is not a directory")),
496                Err(e) => return failure(e.0),
497            },
498        };
499        let secs = a.timeout_secs.unwrap_or(120).clamp(1, MAX_COMMAND_SECS);
500        let mut env = scrubbed_env(std::env::vars());
501        for (k, v) in &self.env {
502            env.retain(|(name, _)| name != k);
503            env.push((k.clone(), v.clone()));
504        }
505        for (k, v) in self.policy.protected().rules().env {
506            env.retain(|(name, _)| name != k);
507            env.push(((*k).to_owned(), (*v).to_owned()));
508        }
509        if let Some(id) = &a.call_id {
510            env.push(("SPECCODER_CALL_ID".to_owned(), id.clone()));
511        }
512        let inv = Invocation {
513            argv: a.argv,
514            cwd,
515            env,
516            timeout: Duration::from_secs(secs),
517            output_cap: OUTPUT_CAP,
518        };
519        // Tests run model-written code: what a command changes among the
520        // judge's inputs is found now, not at the end of the attempt.
521        let judged_before = crate::integrity::JudgeSnapshot::take(&self.root, &self.policy);
522        let runner = self.runner.clone();
523        let network = a.network == Some(true);
524        let outcome =
525            match tokio::task::spawn_blocking(move || runner.run_with(&inv, network)).await {
526                Ok(o) => o,
527                Err(e) => return failure(format!("the command runner failed: {e}")),
528            };
529        if let Some(e) = outcome.spawn_error {
530            return failure(e);
531        }
532        let tail = |b: &[u8]| {
533            let start = b.len().saturating_sub(16 * 1024);
534            String::from_utf8_lossy(&b[start..]).into_owned()
535        };
536        let drift = judged_before.drift(
537            &crate::integrity::JudgeSnapshot::take(&self.root, &self.policy),
538            &self.policy,
539            &std::collections::BTreeSet::new(),
540        );
541        let mut result = json!({
542            "ok": outcome.exit_code == Some(0),
543            "exit_code": outcome.exit_code,
544            "timed_out": outcome.timed_out,
545            "duration_ms": u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX),
546            "stdout": tail(&outcome.stdout.kept),
547            "stderr": tail(&outcome.stderr.kept),
548            "truncated": outcome.stdout.truncated() || outcome.stderr.truncated(),
549        });
550        if !drift.is_empty() {
551            result["integrity"] = json!(drift);
552        }
553        result
554    }
555
556    fn git_status(&self) -> Value {
557        match Git::at(&self.root).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])
558        {
559            Ok(out) => {
560                let entries: Vec<String> = nul_fields(&out);
561                json!({ "ok": true, "entries": entries })
562            }
563            Err(e) => failure(e.0),
564        }
565    }
566
567    fn git_diff(&self, a: &GitDiff) -> Value {
568        let mut args = vec!["diff", "--no-color", "HEAD"];
569        let path;
570        if let Some(p) = &a.path {
571            path = match paths::normalise(p) {
572                Ok(p) => p,
573                Err(e) => return failure(e.0),
574            };
575            args.extend(["--", path.as_str()]);
576        }
577        match Git::at(&self.root).run(&args) {
578            Ok(out) => {
579                let mut diff = String::from_utf8_lossy(&out).into_owned();
580                let truncated = diff.len() > READ_CAP;
581                if truncated {
582                    let mut cut = READ_CAP;
583                    while !diff.is_char_boundary(cut) {
584                        cut -= 1;
585                    }
586                    diff.truncate(cut);
587                }
588                json!({ "ok": true, "diff": diff, "truncated": truncated })
589            }
590            Err(e) => failure(e.0),
591        }
592    }
593}
594
595#[async_trait]
596impl ToolClient for WorkspaceTools {
597    async fn call(
598        &self,
599        tool: &ToolId,
600        arguments: &Value,
601        _provenance: Option<&agentplane::core::Provenance>,
602    ) -> Result<Value, ToolError> {
603        let known = catalog()
604            .into_iter()
605            .any(|t| t.server == tool.server && t.name == tool.tool);
606        if !known {
607            return Err(ToolError::Refused {
608                tool: tool.clone(),
609                detail: "not a speccoder tool".to_owned(),
610            });
611        }
612        Ok(self.run(&tool.tool, arguments).await)
613    }
614
615    /// The tools touch only the local worktree. (A command the model runs
616    /// may still use the network; that is the policy's and, in M6, the
617    /// sandbox's concern, not a transport this client opens.)
618    fn destination(&self, _tool: &ToolId) -> Destination {
619        Destination::Local
620    }
621}