1pub 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
28pub const READ_CAP: usize = 256 * 1024;
30pub const LIST_CAP: usize = 500;
32pub const SEARCH_CAP: usize = 200;
34pub const LINE_CAP: usize = 500;
36pub const MAX_COMMAND_SECS: u64 = 600;
38
39#[derive(Debug, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct ReadFile {
45 pub path: String,
47 pub start_line: Option<usize>,
49 pub end_line: Option<usize>,
51}
52
53#[derive(Debug, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct ListFiles {
57 pub path: Option<String>,
59 pub glob: Option<String>,
61}
62
63#[derive(Debug, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct SearchCode {
67 pub pattern: String,
69 pub path: Option<String>,
71 pub glob: Option<String>,
73}
74
75#[derive(Debug, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct WriteFile {
79 pub path: String,
81 pub content: String,
83 #[schemars(skip)]
86 #[serde(default, rename = "__expect")]
87 pub expect: Option<Object>,
88}
89
90#[derive(Debug, Deserialize, JsonSchema)]
92#[serde(deny_unknown_fields)]
93pub struct EditFile {
94 pub path: String,
96 pub old_string: String,
98 pub new_string: String,
100 pub replace_all: Option<bool>,
102 #[schemars(skip)]
105 #[serde(default, rename = "__expect")]
106 pub expect: Option<Object>,
107}
108
109#[derive(Debug, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct RunCommand {
113 pub argv: Vec<String>,
115 pub cwd: Option<String>,
117 pub timeout_secs: Option<u64>,
119 pub network: Option<bool>,
121 #[schemars(skip)]
124 #[serde(default)]
125 pub call_id: Option<String>,
126 #[schemars(skip)]
129 #[serde(default, rename = "__expect")]
130 pub expect: Option<Object>,
131}
132
133#[derive(Debug, Deserialize, JsonSchema)]
136#[serde(deny_unknown_fields)]
137pub struct Resolve {
138 pub tool: String,
140 pub arguments: Value,
142}
143
144pub const EXPECT: &str = "__expect";
146
147#[derive(Debug, Deserialize, JsonSchema)]
149#[serde(deny_unknown_fields)]
150pub struct GitStatus {}
151
152#[derive(Debug, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct GitDiff {
156 pub path: Option<String>,
158}
159
160#[derive(Debug, Clone)]
162pub struct ToolSpec {
163 pub name: &'static str,
165 pub server: &'static str,
167 pub mutates: bool,
169 pub model_fields: &'static [&'static str],
171 pub description: &'static str,
173 pub parameters: fn() -> Value,
175}
176
177fn schema<T: JsonSchema>() -> Value {
181 agentplane::tools::strict_schema::<T>()
182}
183
184#[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#[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#[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#[derive(Debug, Clone)]
282pub struct WorkspaceTools {
283 root: PathBuf,
284 policy: Policy,
285 runner: crate::sandbox::Runner,
286 env: Vec<(String, String)>,
288}
289
290fn 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
312pub(crate) fn below_root(dir: Option<&str>) -> Option<&str> {
315 dir.filter(|d| !matches!(d.trim(), "" | "." | "./"))
316}
317
318fn 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
328fn 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 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 #[must_use]
362 pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
363 self.env = env;
364 self
365 }
366
367 #[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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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(¬e),
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}