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, canonical_for_write, normalise, resolve_existing};
24use self::policy::{Decision, Object, 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    /// Set by speccoder, not the model: the object a person's decision
84    /// covered, checked again before the write.
85    #[schemars(skip)]
86    #[serde(default, rename = "__expect")]
87    pub expect: Option<Object>,
88}
89
90/// Replace an exact string in a file: once (it must occur exactly once) or everywhere.
91#[derive(Debug, Deserialize, JsonSchema)]
92#[serde(deny_unknown_fields)]
93pub struct EditFile {
94    /// Path relative to the workspace root.
95    pub path: String,
96    /// Exact text to find, including whitespace.
97    pub old_string: String,
98    /// Replacement text.
99    pub new_string: String,
100    /// Replace every occurrence instead of exactly one (default: one).
101    pub replace_all: Option<bool>,
102    /// Set by speccoder, not the model: the object a person's decision
103    /// covered, checked again before the edit.
104    #[schemars(skip)]
105    #[serde(default, rename = "__expect")]
106    pub expect: Option<Object>,
107}
108
109/// Run a program (no shell) in the workspace with a timeout; stdin is closed.
110#[derive(Debug, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct RunCommand {
113    /// Program and arguments, e.g. ["cargo", "test", "-p", "core"].
114    pub argv: Vec<String>,
115    /// Working directory relative to the workspace root (default: the root).
116    pub cwd: Option<String>,
117    /// Seconds before the whole process group is killed (default 120, at most 600).
118    pub timeout_secs: Option<u64>,
119    /// Commands run without the network; `true` asks for it (a person decides).
120    pub network: Option<bool>,
121    /// Set by speccoder, not the model: the call's id, exported as
122    /// `SPECCODER_CALL_ID`.
123    #[schemars(skip)]
124    #[serde(default)]
125    pub call_id: Option<String>,
126    /// Set by speccoder, not the model: the object a person's decision
127    /// covered, checked again before the command starts.
128    #[schemars(skip)]
129    #[serde(default, rename = "__expect")]
130    pub expect: Option<Object>,
131}
132
133/// speccoder's own step, never offered to the model: the object a call
134/// would reach — the file a write lands in, the program a command runs.
135#[derive(Debug, Deserialize, JsonSchema)]
136#[serde(deny_unknown_fields)]
137pub struct Resolve {
138    /// The tool the model asked for.
139    pub tool: String,
140    /// Its arguments as the model sent them.
141    pub arguments: Value,
142}
143
144/// The internal field the tool loop sets on a call decided on an object.
145pub const EXPECT: &str = "__expect";
146
147/// Show changed, added and untracked files.
148#[derive(Debug, Deserialize, JsonSchema)]
149#[serde(deny_unknown_fields)]
150pub struct GitStatus {}
151
152/// Show the diff of the working tree against HEAD, optionally for one path.
153#[derive(Debug, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct GitDiff {
156    /// Limit the diff to this path.
157    pub path: Option<String>,
158}
159
160/// One tool as the model and agentplane see it.
161#[derive(Debug, Clone)]
162pub struct ToolSpec {
163    /// Name offered to the model, e.g. `read_file`.
164    pub name: &'static str,
165    /// agentplane tool server: `fs`, `proc` or `git`.
166    pub server: &'static str,
167    /// Whether calling it can change the world.
168    pub mutates: bool,
169    /// Argument pointers the model authors (declared on mutating grants).
170    pub model_fields: &'static [&'static str],
171    /// What the model is told.
172    pub description: &'static str,
173    /// JSON schema of the arguments.
174    pub parameters: fn() -> Value,
175}
176
177/// The schema of a tool's arguments, made strict-ready by agentplane: only
178/// what serde enforces is declared — an optional argument is an `Option`,
179/// so `null` is both allowed and readable.
180fn schema<T: JsonSchema>() -> Value {
181    agentplane::tools::strict_schema::<T>()
182}
183
184/// Every tool, in a stable order (the order is part of the cached prompt prefix).
185#[must_use]
186pub fn catalog() -> Vec<ToolSpec> {
187    vec![
188        ToolSpec {
189            name: "read_file",
190            server: "fs",
191            mutates: false,
192            model_fields: &[],
193            description: "Read a text file in the workspace, optionally a range of lines.",
194            parameters: schema::<ReadFile>,
195        },
196        ToolSpec {
197            name: "list_files",
198            server: "fs",
199            mutates: false,
200            model_fields: &[],
201            description: "List files not ignored by .gitignore, optionally under a directory and matching a glob.",
202            parameters: schema::<ListFiles>,
203        },
204        ToolSpec {
205            name: "search_code",
206            server: "fs",
207            mutates: false,
208            model_fields: &[],
209            description: "Search file contents with a regular expression; returns path:line: text.",
210            parameters: schema::<SearchCode>,
211        },
212        ToolSpec {
213            name: "write_file",
214            server: "fs",
215            mutates: true,
216            model_fields: &["/path", "/content"],
217            description: "Create or replace a whole file. Spec Kit files, CI and test configuration, speccoder.toml and .git are refused.",
218            parameters: schema::<WriteFile>,
219        },
220        ToolSpec {
221            name: "edit_file",
222            server: "fs",
223            mutates: true,
224            model_fields: &["/path", "/old_string", "/new_string"],
225            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.",
226            parameters: schema::<EditFile>,
227        },
228        ToolSpec {
229            name: "run_command",
230            server: "proc",
231            mutates: true,
232            model_fields: &["/argv"],
233            description: "Run a program without a shell in the workspace; stdin is closed, output is capped, the timeout kills the process group.",
234            parameters: schema::<RunCommand>,
235        },
236        ToolSpec {
237            name: "git_status",
238            server: "git",
239            mutates: false,
240            model_fields: &[],
241            description: "Show modified, added, deleted and untracked files.",
242            parameters: schema::<GitStatus>,
243        },
244        ToolSpec {
245            name: "git_diff",
246            server: "git",
247            mutates: false,
248            model_fields: &[],
249            description: "Show the working tree's diff against HEAD.",
250            parameters: schema::<GitDiff>,
251        },
252    ]
253}
254
255/// speccoder's own tools, which the tool loop calls and the model is never
256/// offered: `fs/resolve`.
257#[must_use]
258pub fn internal() -> Vec<ToolSpec> {
259    vec![ToolSpec {
260        name: "resolve",
261        server: "fs",
262        mutates: false,
263        model_fields: &[],
264        description: "Resolve the object a call would reach (speccoder's own step).",
265        parameters: schema::<Resolve>,
266    }]
267}
268
269/// The model-facing declarations, in catalog order.
270#[must_use]
271pub fn declarations() -> Vec<ToolDeclaration> {
272    catalog()
273        .into_iter()
274        .map(|t| ToolDeclaration::new(t.name, t.description, (t.parameters)()))
275        .collect()
276}
277
278// ── The tool server ─────────────────────────────────────────────────────────
279
280/// Tools bound to one worktree.
281#[derive(Debug, Clone)]
282pub struct WorkspaceTools {
283    root: PathBuf,
284    policy: Policy,
285    runner: crate::sandbox::Runner,
286    /// Variables every command gets (a hosted skill's current unit).
287    env: Vec<(String, String)>,
288}
289
290/// A write that would take tests away, refused before it happens: a test
291/// that passed before the attempt is evidence, and its removal fails the
292/// attempt whatever else it did. The model is told which, and what to do.
293fn removes_tests(path: &str, before: &str, after: &str) -> Option<Value> {
294    let gone = crate::integrity::removed_tests(before, after);
295    if gone.is_empty() {
296        return None;
297    }
298    let (s, them) = if gone.len() == 1 {
299        ("", "it")
300    } else {
301        ("s", "them")
302    };
303    Some(failure(format!(
304        "refused: this removes the test{s} {} from `{path}`. Tests that passed before the attempt are evidence: removing or renaming one fails the attempt. Keep {them} and add new tests beside {them}; if one contradicts the spec, report the task blocked and say which test and why.",
305        gone.iter()
306            .map(|n| format!("`{n}`"))
307            .collect::<Vec<_>>()
308            .join(", ")
309    )))
310}
311
312/// A directory argument below the root, or `None` for the root itself:
313/// `.`, `./` and an empty string mean the workspace root (models pass them).
314pub(crate) fn below_root(dir: Option<&str>) -> Option<&str> {
315    dir.filter(|d| !matches!(d.trim(), "" | "." | "./"))
316}
317
318/// What the sandbox denied a failed command, for its result: `None` when
319/// there is neither a denial nor a note.
320fn sandbox_json(denials: &speccoder_core::verify::Denials) -> Option<Value> {
321    (!denials.is_empty()).then(|| json!(denials))
322}
323
324fn failure(error: impl Into<String>) -> Value {
325    json!({ "ok": false, "error": error.into() })
326}
327
328/// The refusal of a call whose object differs from the one a person
329/// decided on: the model is told, the record names both.
330fn changed(was: &str, now: &str) -> Value {
331    json!({
332        "ok": false,
333        "error": format!("the object changed since it was approved: {was} → {now}"),
334        "object_changed": { "expected": was, "found": now },
335    })
336}
337
338fn sha(bytes: &[u8]) -> String {
339    Sha256::digest(bytes)
340        .iter()
341        .map(|b| format!("{b:02x}"))
342        .collect()
343}
344
345fn parse<T: for<'de> Deserialize<'de>>(args: &Value) -> Result<T, Value> {
346    serde_json::from_value(args.clone()).map_err(|e| failure(format!("invalid arguments: {e}")))
347}
348
349impl WorkspaceTools {
350    /// Tools for the worktree at `root` under `policy`.
351    pub fn new(root: &Path, policy: Policy) -> std::io::Result<Self> {
352        Ok(Self {
353            root: fs::canonicalize(root)?,
354            policy,
355            runner: crate::sandbox::Runner::Plain,
356            env: Vec::new(),
357        })
358    }
359
360    /// Commands also get `env` (the policy's own variables still win).
361    #[must_use]
362    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
363        self.env = env;
364        self
365    }
366
367    /// Runs commands with `runner` (the sandbox).
368    #[must_use]
369    pub fn with_runner(mut self, runner: crate::sandbox::Runner) -> Self {
370        self.runner = runner;
371        self
372    }
373
374    fn protected(&self) -> &Protected {
375        self.policy.protected()
376    }
377
378    /// Runs one tool by name; the result is what the model sees.
379    pub async fn run(&self, name: &str, args: &Value) -> Value {
380        if name == "resolve" {
381            return match parse::<Resolve>(args) {
382                Ok(a) => self.resolve(&a.tool, &a.arguments),
383                Err(e) => e,
384            };
385        }
386        // Denials are enforced here too, whatever the caller decided.
387        if let Decision::Deny(reason) = self.policy.classify(name, args, None) {
388            return failure(format!("denied: {reason}"));
389        }
390        let out = match name {
391            "read_file" => parse(args).map(|a| self.read_file(&a)),
392            "list_files" => parse(args).map(|a| self.list_files(&a)),
393            "search_code" => parse(args).map(|a| self.search_code(&a)),
394            "write_file" => parse(args).map(|a| self.write_file(&a)),
395            "edit_file" => parse(args).map(|a| self.edit_file(&a)),
396            "run_command" => match parse::<RunCommand>(args) {
397                Ok(a) => Ok(self.run_command(a).await),
398                Err(e) => Err(e),
399            },
400            "git_status" => parse::<GitStatus>(args).map(|_| self.git_status()),
401            "git_diff" => parse(args).map(|a| self.git_diff(&a)),
402            other => Err(failure(format!("unknown tool `{other}`"))),
403        };
404        // A value copied from the person's secret files never reaches the
405        // model, nor the journal its results are kept in.
406        let mut out = out.unwrap_or_else(|e| e);
407        crate::secrets::mask_json(&mut out);
408        out
409    }
410
411    fn rel(&self, p: &Path) -> String {
412        p.strip_prefix(&self.root)
413            .unwrap_or(p)
414            .to_string_lossy()
415            .replace('\\', "/")
416    }
417
418    fn read_file(&self, a: &ReadFile) -> Value {
419        let path = match resolve_existing(&self.root, &a.path) {
420            Ok(p) => p,
421            Err(e) => return failure(e.0),
422        };
423        if self.protected().is_secret(&self.rel(&path)) {
424            return failure(format!("`{}` looks like a credential", a.path));
425        }
426        let bytes = match fs::read(&path) {
427            Ok(b) => b,
428            Err(e) => return failure(format!("`{}`: {e}", a.path)),
429        };
430        let Ok(text) = String::from_utf8(bytes.clone()) else {
431            return json!({ "ok": true, "path": a.path, "binary": true, "bytes": bytes.len() });
432        };
433        let lines: Vec<&str> = text.lines().collect();
434        let start = a.start_line.unwrap_or(1).max(1);
435        let end = a.end_line.unwrap_or(lines.len()).min(lines.len());
436        // Masked before any cut: a cut value would leave its start shown.
437        let mut content = if start > end {
438            String::new()
439        } else {
440            crate::secrets::mask(&lines[start - 1..end].join("\n"))
441        };
442        let truncated = content.len() > READ_CAP;
443        if truncated {
444            let mut cut = READ_CAP;
445            while !content.is_char_boundary(cut) {
446                cut -= 1;
447            }
448            content.truncate(cut);
449            content.push_str("\n[… truncated; read a line range …]");
450        }
451        json!({ "ok": true, "path": a.path, "start_line": start, "end_line": end, "total_lines": lines.len(), "truncated": truncated, "content": content })
452    }
453
454    fn walk(&self, dir: Option<&str>, glob: Option<&str>) -> Result<Vec<PathBuf>, Value> {
455        let base = match below_root(dir) {
456            Some(d) => resolve_existing(&self.root, d).map_err(|e| failure(e.0))?,
457            None => self.root.clone(),
458        };
459        let matcher = glob
460            .map(|g| globset::Glob::new(g).map(|g| g.compile_matcher()))
461            .transpose()
462            .map_err(|e| failure(format!("invalid glob: {e}")))?;
463        let mut files: Vec<PathBuf> = ignore::WalkBuilder::new(&base)
464            .hidden(false)
465            .filter_entry(|e| e.file_name() != ".git")
466            .build()
467            .flatten()
468            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
469            .map(ignore::DirEntry::into_path)
470            .filter(|p| {
471                let rel = self.rel(p);
472                !self.protected().is_secret(&rel)
473                    && matcher.as_ref().is_none_or(|m| m.is_match(&rel))
474            })
475            .collect();
476        files.sort();
477        Ok(files)
478    }
479
480    fn list_files(&self, a: &ListFiles) -> Value {
481        match self.walk(a.path.as_deref(), a.glob.as_deref()) {
482            Ok(files) => {
483                let total = files.len();
484                let shown: Vec<String> = files.iter().take(LIST_CAP).map(|p| self.rel(p)).collect();
485                json!({ "ok": true, "files": shown, "total": total, "truncated": total > LIST_CAP })
486            }
487            Err(e) => e,
488        }
489    }
490
491    fn search_code(&self, a: &SearchCode) -> Value {
492        let re = match regex::Regex::new(&a.pattern) {
493            Ok(r) => r,
494            Err(e) => return failure(format!("invalid pattern: {e}")),
495        };
496        let files = match self.walk(a.path.as_deref(), a.glob.as_deref()) {
497            Ok(f) => f,
498            Err(e) => return e,
499        };
500        let mut matches = Vec::new();
501        let mut total = 0usize;
502        for f in files {
503            let Ok(bytes) = fs::read(&f) else { continue };
504            if bytes.len() > 1024 * 1024 || bytes.iter().take(8192).any(|b| *b == 0) {
505                continue;
506            }
507            let text = String::from_utf8_lossy(&bytes);
508            for (i, line) in text.lines().enumerate() {
509                if re.is_match(line) {
510                    total += 1;
511                    if matches.len() < SEARCH_CAP {
512                        let shown: String =
513                            crate::secrets::mask(line).chars().take(LINE_CAP).collect();
514                        matches.push(format!("{}:{}: {shown}", self.rel(&f), i + 1));
515                    }
516                }
517            }
518        }
519        json!({ "ok": true, "matches": matches, "total": total, "truncated": total > SEARCH_CAP })
520    }
521
522    /// The object `tool` would reach with `arguments`: what a person's
523    /// decision covers (`{ok, object}`; `object` is `null` for a tool
524    /// without one, `ok: false` with the refusal when it cannot be reached).
525    #[must_use]
526    pub fn resolve(&self, tool: &str, arguments: &Value) -> Value {
527        match tool {
528            "write_file" | "edit_file" => {
529                let asked = arguments["path"].as_str().unwrap_or_default();
530                match (canonical_for_write(&self.root, asked), normalise(asked)) {
531                    (Ok(canonical), Ok(asked)) => json!({
532                        "ok": true,
533                        "object": Object::Path { canonical, asked },
534                    }),
535                    (Err(e), _) | (_, Err(e)) => failure(e.0),
536                }
537            }
538            "run_command" => {
539                let argv = policy::argv_of(arguments);
540                match self.command_object(
541                    &argv,
542                    arguments["cwd"].as_str(),
543                    arguments["network"] == true,
544                ) {
545                    Ok(object) => json!({ "ok": true, "object": object }),
546                    Err(e) => e,
547                }
548            }
549            _ => json!({ "ok": true, "object": null }),
550        }
551    }
552
553    /// The directory a command runs in.
554    pub(crate) fn command_cwd(&self, cwd: Option<&str>) -> Result<PathBuf, Value> {
555        match below_root(cwd) {
556            None => Ok(self.root.clone()),
557            Some(d) => match resolve_existing(&self.root, d) {
558                Ok(p) if p.is_dir() => Ok(p),
559                Ok(_) => Err(failure(format!("`{d}` is not a directory"))),
560                Err(e) => Err(failure(e.0)),
561            },
562        }
563    }
564
565    /// The environment every command gets.
566    pub(crate) fn command_env(&self, call_id: Option<&str>) -> Vec<(String, String)> {
567        let mut env = scrubbed_env(std::env::vars());
568        for (k, v) in &self.env {
569            env.retain(|(name, _)| name != k);
570            env.push((k.clone(), v.clone()));
571        }
572        let rules = self.policy.protected().rules();
573        for (k, v) in rules.env {
574            env.retain(|(name, _)| name != k);
575            env.push(((*k).to_owned(), (*v).to_owned()));
576        }
577        // Found on this machine: never in place of a value already there.
578        let found = (rules.found_env)(&|k: &str| {
579            env.iter()
580                .find(|(name, _)| name == k)
581                .map(|(_, v)| v.clone())
582        });
583        for (k, v) in found {
584            if !env.iter().any(|(name, _)| *name == k) {
585                env.push((k, v));
586            }
587        }
588        if let Some(id) = call_id {
589            env.push(("SPECCODER_CALL_ID".to_owned(), id.to_owned()));
590        }
591        env
592    }
593
594    /// A command's object: its argv, the directory it runs in and the
595    /// executable that directory and environment resolve it to. An
596    /// executable inside the workspace is named with the digest of its
597    /// content: the model can rewrite it, and a rewritten one is another
598    /// object (asked again, and refused at use when it changed after the
599    /// decision).
600    fn command_object(
601        &self,
602        argv: &[String],
603        cwd: Option<&str>,
604        network: bool,
605    ) -> Result<Object, Value> {
606        let dir = self.command_cwd(cwd)?;
607        let rel = self.rel(&dir);
608        let executable = argv
609            .first()
610            .and_then(|p| crate::sandbox::resolve_executable(p, &dir, &self.command_env(None)))
611            .map_or_else(
612                || policy::NOT_FOUND.to_owned(),
613                |p| {
614                    if p.starts_with(&self.root) {
615                        let content = std::fs::read(&p).unwrap_or_default();
616                        format!(
617                            "{} (sha256 {})",
618                            p.display(),
619                            crate::integrity::sha256_hex(&content)
620                        )
621                    } else {
622                        p.display().to_string()
623                    }
624                },
625            );
626        Ok(Object::command(
627            argv.to_vec(),
628            if rel.is_empty() { ".".to_owned() } else { rel },
629            executable,
630            network,
631        ))
632    }
633
634    /// The file a write reaches, refused when protected — judged on the
635    /// file itself, so a symlinked directory cannot carry a write into the
636    /// protected set — or when it is not the object a person's decision
637    /// covered (resolved again now: it may have changed since).
638    fn write_checked(&self, rel: &str, expect: Option<&Object>) -> Result<PathBuf, Value> {
639        let canonical = canonical_for_write(&self.root, rel).map_err(|e| failure(e.0))?;
640        if let Some(class) = self.policy.write_protected(&canonical) {
641            return Err(failure(format!(
642                "denied: `{rel}` is protected: {}",
643                self.policy.protected().reason(class)
644            )));
645        }
646        match expect {
647            Some(Object::Path { canonical: was, .. }) if *was != canonical => {
648                return Err(changed(was, &canonical));
649            }
650            Some(_) => {}
651            // Allowed by a rule on the path as asked: the rules that need
652            // a person (a frozen test, a context file, a manifest) were
653            // judged on that spelling. One that reaches another file — a
654            // symlinked directory, another letter case — is written by
655            // the name of the file it reaches, and judged on that.
656            None => {
657                let asked = normalise(rel).map_err(|e| failure(e.0))?;
658                if asked != canonical {
659                    return Err(failure(format!(
660                        "refused: `{asked}` reaches `{canonical}`; write it by that path"
661                    )));
662                }
663            }
664        }
665        Ok(self.root.join(canonical))
666    }
667
668    fn write_file(&self, a: &WriteFile) -> Value {
669        let path = match self.write_checked(&a.path, a.expect.as_ref()) {
670            Ok(p) => p,
671            Err(e) => return e,
672        };
673        if path.is_dir() {
674            return failure(format!("`{}` is a directory", a.path));
675        }
676        let before = fs::read(&path)
677            .ok()
678            .map_or_else(|| "absent".to_owned(), |b| sha(&b));
679        if let Ok(old) = fs::read_to_string(&path)
680            && let Some(refused) = removes_tests(&a.path, &old, &a.content)
681        {
682            return refused;
683        }
684        if let Some(parent) = path.parent()
685            && let Err(e) = fs::create_dir_all(parent)
686        {
687            return failure(format!("`{}`: {e}", a.path));
688        }
689        if let Err(e) = fs::write(&path, &a.content) {
690            return failure(format!("`{}`: {e}", a.path));
691        }
692        json!({ "ok": true, "path": a.path, "before": before, "after": sha(a.content.as_bytes()), "bytes": a.content.len() })
693    }
694
695    fn edit_file(&self, a: &EditFile) -> Value {
696        let path = match self.write_checked(&a.path, a.expect.as_ref()) {
697            Ok(p) => p,
698            Err(e) => return e,
699        };
700        let Ok(text) = fs::read_to_string(&path) else {
701            return failure(format!("`{}` does not exist or is not text", a.path));
702        };
703        if a.old_string.is_empty() {
704            return failure("`old_string` is empty");
705        }
706        let count = text.matches(&a.old_string).count();
707        let replace_all = a.replace_all.unwrap_or(false);
708        let new = match (count, replace_all) {
709            (0, _) => return failure(format!("`old_string` not found in `{}`", a.path)),
710            (1, _) | (_, true) => text.replace(&a.old_string, &a.new_string),
711            (n, false) => {
712                return failure(format!(
713                    "`old_string` occurs {n} times in `{}`; add context to make it unique or set replace_all",
714                    a.path
715                ));
716            }
717        };
718        if let Some(refused) = removes_tests(&a.path, &text, &new) {
719            return refused;
720        }
721        if let Err(e) = fs::write(&path, &new) {
722            return failure(format!("`{}`: {e}", a.path));
723        }
724        json!({ "ok": true, "path": a.path, "replacements": if replace_all { count } else { 1 }, "before": sha(text.as_bytes()), "after": sha(new.as_bytes()) })
725    }
726
727    async fn run_command(&self, a: RunCommand) -> Value {
728        let cwd = match self.command_cwd(a.cwd.as_deref()) {
729            Ok(c) => c,
730            Err(e) => return e,
731        };
732        // A command decided on its object runs only as that object: the
733        // same directory, the same executable — resolved again now.
734        if let Some(Object::Command {
735            cwd: was_cwd,
736            executable: was_exe,
737            ..
738        }) = &a.expect
739        {
740            match self.command_object(&a.argv, a.cwd.as_deref(), a.network == Some(true)) {
741                Ok(Object::Command {
742                    cwd, executable, ..
743                }) if cwd != *was_cwd || executable != *was_exe => {
744                    return changed(
745                        &format!("{was_exe} in {was_cwd}"),
746                        &format!("{executable} in {cwd}"),
747                    );
748                }
749                Ok(_) => {}
750                Err(e) => return e,
751            }
752        }
753        let secs = a.timeout_secs.unwrap_or(120).clamp(1, MAX_COMMAND_SECS);
754        let env = self.command_env(a.call_id.as_deref());
755        let inv = Invocation {
756            argv: a.argv,
757            cwd,
758            env,
759            timeout: Duration::from_secs(secs),
760            output_cap: OUTPUT_CAP,
761        };
762        // Tests run model-written code: what a command changes among the
763        // judge's inputs is found now, not at the end of the attempt.
764        let judged_before = crate::integrity::JudgeSnapshot::take(&self.root, &self.policy);
765        let runner = self.runner.clone();
766        let network = a.network == Some(true);
767        let outcome =
768            match tokio::task::spawn_blocking(move || runner.run_with(&inv, network)).await {
769                Ok(o) => o,
770                Err(e) => return failure(format!("the command runner failed: {e}")),
771            };
772        if let Some(e) = outcome.spawn_error {
773            return failure(e);
774        }
775        let tail = |b: &[u8]| {
776            let start = b.len().saturating_sub(16 * 1024);
777            String::from_utf8_lossy(&b[start..]).into_owned()
778        };
779        let drift = judged_before.drift(
780            &crate::integrity::JudgeSnapshot::take(&self.root, &self.policy),
781            &self.policy,
782            &std::collections::BTreeSet::new(),
783        );
784        let mut result = json!({
785            "ok": outcome.exit_code == Some(0),
786            "exit_code": outcome.exit_code,
787            "timed_out": outcome.timed_out,
788            "duration_ms": u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX),
789            "stdout": tail(&outcome.stdout.kept),
790            "stderr": tail(&outcome.stderr.kept),
791            "truncated": outcome.stdout.truncated() || outcome.stderr.truncated(),
792        });
793        if !drift.is_empty() {
794            result["integrity"] = json!(drift);
795        }
796        // Why a command failed is often the sandbox: say what it refused.
797        if let Some(sandbox) = sandbox_json(&outcome.sandbox) {
798            result["sandbox"] = sandbox;
799        }
800        result
801    }
802
803    fn git_status(&self) -> Value {
804        match Git::at(&self.root).run(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])
805        {
806            Ok(out) => {
807                let entries: Vec<String> = nul_fields(&out);
808                json!({ "ok": true, "entries": entries })
809            }
810            Err(e) => failure(e.0),
811        }
812    }
813
814    fn git_diff(&self, a: &GitDiff) -> Value {
815        let mut args = vec!["diff", "--no-color", "HEAD"];
816        let path;
817        if let Some(p) = &a.path {
818            path = match paths::normalise(p) {
819                Ok(p) => p,
820                Err(e) => return failure(e.0),
821            };
822            args.extend(["--", path.as_str()]);
823        }
824        match Git::at(&self.root).run(&args) {
825            Ok(out) => {
826                let mut diff = crate::secrets::mask(&String::from_utf8_lossy(&out));
827                let truncated = diff.len() > READ_CAP;
828                if truncated {
829                    let mut cut = READ_CAP;
830                    while !diff.is_char_boundary(cut) {
831                        cut -= 1;
832                    }
833                    diff.truncate(cut);
834                }
835                json!({ "ok": true, "diff": diff, "truncated": truncated })
836            }
837            Err(e) => failure(e.0),
838        }
839    }
840}
841
842#[async_trait]
843impl ToolClient for WorkspaceTools {
844    async fn call(
845        &self,
846        tool: &ToolId,
847        arguments: &Value,
848        _provenance: Option<&agentplane::core::Provenance>,
849    ) -> Result<Value, ToolError> {
850        let known = catalog()
851            .into_iter()
852            .chain(internal())
853            .any(|t| t.server == tool.server && t.name == tool.tool);
854        if !known {
855            return Err(ToolError::Refused {
856                tool: tool.clone(),
857                detail: "not a speccoder tool".to_owned(),
858            });
859        }
860        Ok(self.run(&tool.tool, arguments).await)
861    }
862
863    /// The tools touch only the local worktree. (A command the model runs
864    /// may still use the network; that is the policy's and, in M6, the
865    /// sandbox's concern, not a transport this client opens.)
866    fn destination(&self, _tool: &ToolId) -> Destination {
867        Destination::Local
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874    use speccoder_core::verify::Denials;
875
876    #[test]
877    fn the_sandbox_field_is_there_only_with_something_to_say() {
878        assert_eq!(sandbox_json(&Denials::default()), None);
879        let note = Denials {
880            note: Some("only refused system calls are reported on Linux".into()),
881            ..Denials::default()
882        };
883        assert_eq!(
884            sandbox_json(&note),
885            Some(json!({"note": "only refused system calls are reported on Linux"}))
886        );
887        let listed = Denials {
888            denied: vec!["git: file-write-data /w/.git/config — read-only".into()],
889            more: 3,
890            note: None,
891        };
892        assert_eq!(
893            sandbox_json(&listed),
894            Some(json!({"denied": ["git: file-write-data /w/.git/config — read-only"], "more": 3}))
895        );
896    }
897}