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, 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
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}
84
85#[derive(Debug, Deserialize, JsonSchema)]
87#[serde(deny_unknown_fields)]
88pub struct EditFile {
89 pub path: String,
91 pub old_string: String,
93 pub new_string: String,
95 pub replace_all: Option<bool>,
97}
98
99#[derive(Debug, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields)]
102pub struct RunCommand {
103 pub argv: Vec<String>,
105 pub cwd: Option<String>,
107 pub timeout_secs: Option<u64>,
109 pub network: Option<bool>,
111 #[schemars(skip)]
114 #[serde(default)]
115 pub call_id: Option<String>,
116}
117
118#[derive(Debug, Deserialize, JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct GitStatus {}
122
123#[derive(Debug, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct GitDiff {
127 pub path: Option<String>,
129}
130
131#[derive(Debug, Clone)]
133pub struct ToolSpec {
134 pub name: &'static str,
136 pub server: &'static str,
138 pub mutates: bool,
140 pub model_fields: &'static [&'static str],
142 pub description: &'static str,
144 pub parameters: fn() -> Value,
146}
147
148fn schema<T: JsonSchema>() -> Value {
152 agentplane::tools::strict_schema::<T>()
153}
154
155#[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#[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#[derive(Debug, Clone)]
239pub struct WorkspaceTools {
240 root: PathBuf,
241 policy: Policy,
242 runner: crate::sandbox::Runner,
243 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 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 #[must_use]
275 pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
276 self.env = env;
277 self
278 }
279
280 #[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 pub async fn run(&self, name: &str, args: &Value) -> Value {
293 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 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 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 fn destination(&self, _tool: &ToolId) -> Destination {
619 Destination::Local
620 }
621}