1use std::{
4 collections::HashMap,
5 ffi::OsString,
6 io::{Read as _, Write as _},
7 os::unix::process::CommandExt as _,
8 path::{Component, Path, PathBuf},
9 sync::{
10 Arc,
11 atomic::{AtomicU64, Ordering},
12 },
13 time::Duration,
14};
15
16use async_trait::async_trait;
17use cap_std::{
18 ambient_authority,
19 fs::{Dir, OpenOptions},
20};
21use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolRisk, ToolSpec};
22use serde::Deserialize;
23use serde_json::{Value, json};
24use sha2::{Digest, Sha256};
25use tokio::{
26 io::AsyncReadExt,
27 process::Command,
28 sync::Mutex,
29 task::JoinHandle,
30 time::{Instant, sleep, sleep_until, timeout, timeout_at},
31};
32
33#[derive(Debug, Clone)]
34pub struct ToolsConfig {
35 pub command_timeout: Duration,
36 pub output_limit_bytes: usize,
37 pub max_read_bytes: usize,
38 pub max_write_bytes: usize,
39}
40
41impl Default for ToolsConfig {
42 fn default() -> Self {
43 Self {
44 command_timeout: Duration::from_secs(120),
45 output_limit_bytes: 64 * 1024,
46 max_read_bytes: 256 * 1024,
47 max_write_bytes: 1024 * 1024,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct AgentAdapterConfig {
54 pub command: String,
55 pub args: Vec<String>,
56}
57
58pub type SkillMap = HashMap<String, PathBuf>;
59
60pub fn builtin_registry(
61 config: ToolsConfig,
62 skills: SkillMap,
63 skill_roots: Vec<PathBuf>,
64 max_skill_bytes: usize,
65 adapters: HashMap<String, AgentAdapterConfig>,
66) -> Result<ToolRegistry, ToolError> {
67 let mut registry = ToolRegistry::default();
68 registry.register(Arc::new(ReadTool {
69 max_bytes: config.max_read_bytes,
70 }))?;
71 registry.register(Arc::new(ReadSkillTool {
72 skills,
73 roots: skill_roots,
74 max_bytes: max_skill_bytes,
75 }))?;
76 registry.register(Arc::new(WriteTool {
77 max_bytes: config.max_write_bytes,
78 }))?;
79 registry.register(Arc::new(BashTool {
80 timeout: config.command_timeout,
81 output_limit: config.output_limit_bytes,
82 }))?;
83 for (name, adapter) in adapters {
84 registry.register(Arc::new(NativeAgentTool::new(
85 name,
86 adapter,
87 config.command_timeout,
88 config.output_limit_bytes,
89 )))?;
90 }
91 Ok(registry)
92}
93
94struct ReadTool {
95 max_bytes: usize,
96}
97
98#[derive(Deserialize)]
99#[serde(deny_unknown_fields)]
100struct ReadArgs {
101 path: String,
102 #[serde(default)]
103 offset: usize,
104 limit: Option<usize>,
105}
106
107#[async_trait]
108impl Tool for ReadTool {
109 fn spec(&self) -> ToolSpec {
110 ToolSpec {
111 name: "read".into(),
112 description: "Read a bounded UTF-8 file inside the workspace".into(),
113 parameters: json!({
114 "type":"object",
115 "properties":{
116 "path":{"type":"string"},
117 "offset":{"type":"integer","minimum":0},
118 "limit":{"type":"integer","minimum":1}
119 },
120 "required":["path"],
121 "additionalProperties":false
122 }),
123 }
124 }
125
126 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
127 let args: ReadArgs = parse_args(arguments)?;
128 validate_read_args(&args)?;
129 Ok(if is_secret_like(Path::new(&args.path)) {
130 ToolRisk::Filesystem
131 } else {
132 ToolRisk::ReadOnly
133 })
134 }
135
136 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
137 let args: ReadArgs = parse_args(arguments)?;
138 validate_read_args(&args)?;
139 Ok(format!("Read {}", args.path))
140 }
141
142 async fn execute(
143 &self,
144 arguments: Value,
145 context: ToolContext,
146 ) -> Result<ToolOutput, ToolError> {
147 let args: ReadArgs = parse_args(&arguments)?;
148 validate_read_args(&args)?;
149 let requested = args.limit.unwrap_or(self.max_bytes).min(self.max_bytes);
150 let offset = u64::try_from(args.offset).unwrap_or(u64::MAX);
151 let workspace = context.workspace.clone();
152 let display_path = args.path.clone();
153 let relative = PathBuf::from(&args.path);
154 validate_relative(&relative)?;
155 let read = tokio::task::spawn_blocking(move || {
156 let root = open_workspace(&workspace)?;
157 let mut file = root
158 .open(&relative)
159 .map_err(|error| map_cap_error("read", &display_path, error))?;
160 let total_bytes = file
161 .metadata()
162 .map_err(|error| ToolError(format!("stat {display_path}: {error}")))?
163 .len();
164 let start = offset.min(total_bytes);
165 std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(start))
166 .map_err(|error| ToolError(format!("seek {display_path}: {error}")))?;
167 let mut bytes = Vec::with_capacity(requested.min(8192));
168 std::io::Read::take(&mut file, u64::try_from(requested).unwrap_or(u64::MAX))
169 .read_to_end(&mut bytes)
170 .map_err(|error| ToolError(format!("read {display_path}: {error}")))?;
171 Ok::<_, ToolError>((bytes, total_bytes, start))
172 });
173 let (bytes, total_bytes, start) = tokio::select! {
174 result = read => result.map_err(|error| ToolError(format!("read task failed: {error}")))??,
175 _ = context.cancellation.cancelled() => return Err(ToolError("read cancelled".into())),
176 };
177 let content = std::str::from_utf8(&bytes)
178 .map_err(|_| ToolError(format!("selected range of {} is not UTF-8", args.path)))?;
179 let end = start.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
180 let truncated = start > 0 || end < total_bytes;
181 Ok(ToolOutput {
182 content: json!({
183 "path": args.path,
184 "content": content,
185 "total_bytes": total_bytes,
186 "offset": start,
187 "truncated": truncated
188 })
189 .to_string(),
190 is_error: false,
191 truncated,
192 })
193 }
194}
195
196struct ReadSkillTool {
197 skills: SkillMap,
198 roots: Vec<PathBuf>,
199 max_bytes: usize,
200}
201
202#[derive(Deserialize)]
203#[serde(deny_unknown_fields)]
204struct ReadSkillArgs {
205 name: String,
206}
207
208#[async_trait]
209impl Tool for ReadSkillTool {
210 fn spec(&self) -> ToolSpec {
211 ToolSpec {
212 name: "read_skill".into(),
213 description: "Load a discovered Peon skill by name".into(),
214 parameters: json!({
215 "type":"object",
216 "properties":{"name":{"type":"string"}},
217 "required":["name"],
218 "additionalProperties":false
219 }),
220 }
221 }
222
223 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
224 let _: ReadSkillArgs = parse_args(arguments)?;
225 Ok(ToolRisk::ReadOnly)
226 }
227
228 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
229 let args: ReadSkillArgs = parse_args(arguments)?;
230 Ok(format!("Load skill {}", args.name))
231 }
232
233 async fn execute(
234 &self,
235 arguments: Value,
236 context: ToolContext,
237 ) -> Result<ToolOutput, ToolError> {
238 let args: ReadSkillArgs = parse_args(&arguments)?;
239 let configured = self
240 .skills
241 .get(&args.name)
242 .ok_or_else(|| ToolError(format!("unknown skill: {}", args.name)))?;
243 let path = std::fs::canonicalize(configured)
244 .map_err(|error| ToolError(format!("load skill {}: {error}", args.name)))?;
245 if !self.roots.iter().any(|root| path.starts_with(root)) {
246 return Err(ToolError("skill path escaped its configured root".into()));
247 }
248 let max_bytes = self.max_bytes;
249 let skill_name = args.name.clone();
250 let bytes = tokio::select! {
251 result = tokio::task::spawn_blocking(move || {
252 let mut file = std::fs::File::open(&path)
253 .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
254 let mut bytes = Vec::with_capacity(max_bytes.min(8192));
255 std::io::Read::take(
256 &mut file,
257 u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1),
258 )
259 .read_to_end(&mut bytes)
260 .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
261 Ok::<_, ToolError>(bytes)
262 }) => result.map_err(|error| ToolError(format!("skill read task failed: {error}")))??,
263 _ = context.cancellation.cancelled() => return Err(ToolError("skill read cancelled".into())),
264 };
265 let end = bytes.len().min(self.max_bytes);
266 let content = std::str::from_utf8(&bytes[..end])
267 .map_err(|_| ToolError("skill is not UTF-8".into()))?;
268 Ok(ToolOutput {
269 content: content.to_owned(),
270 is_error: false,
271 truncated: end < bytes.len(),
272 })
273 }
274}
275
276struct WriteTool {
277 max_bytes: usize,
278}
279
280#[derive(Deserialize)]
281#[serde(deny_unknown_fields)]
282struct WriteArgs {
283 path: String,
284 content: String,
285 mode: WriteMode,
286 expected_sha256: Option<String>,
287}
288
289#[derive(Deserialize)]
290#[serde(rename_all = "snake_case")]
291enum WriteMode {
292 Create,
293 Replace,
294}
295
296#[async_trait]
297impl Tool for WriteTool {
298 fn spec(&self) -> ToolSpec {
299 ToolSpec {
300 name: "write".into(),
301 description: "Atomically create or replace a UTF-8 file inside the workspace".into(),
302 parameters: json!({
303 "type":"object",
304 "properties":{
305 "path":{"type":"string"},
306 "content":{"type":"string"},
307 "mode":{"type":"string","enum":["create","replace"]},
308 "expected_sha256":{"type":"string"}
309 },
310 "required":["path","content","mode"],
311 "additionalProperties":false
312 }),
313 }
314 }
315
316 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
317 let _: WriteArgs = parse_args(arguments)?;
318 Ok(ToolRisk::Filesystem)
319 }
320
321 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
322 let args: WriteArgs = parse_args(arguments)?;
323 let mode = match args.mode {
324 WriteMode::Create => "Create",
325 WriteMode::Replace => "Replace",
326 };
327 Ok(format!(
328 "{mode} {} ({} bytes)",
329 args.path,
330 args.content.len()
331 ))
332 }
333
334 async fn execute(
335 &self,
336 arguments: Value,
337 context: ToolContext,
338 ) -> Result<ToolOutput, ToolError> {
339 let args: WriteArgs = parse_args(&arguments)?;
340 if args.content.len() > self.max_bytes {
341 return Err(ToolError(format!(
342 "write exceeds {} byte limit",
343 self.max_bytes
344 )));
345 }
346 let workspace = context.workspace.clone();
347 let cancellation = context.cancellation.clone();
348 tokio::task::spawn_blocking(move || {
349 if cancellation.is_cancelled() {
350 return Err(ToolError("write cancelled".into()));
351 }
352 let path = PathBuf::from(&args.path);
353 validate_relative(&path)?;
354 let root = open_workspace(&workspace)?;
355 let exists = match root.symlink_metadata(&path) {
356 Ok(_) => true,
357 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
358 Err(error) => return Err(map_cap_error("inspect", &args.path, error)),
359 };
360 match args.mode {
361 WriteMode::Create if exists => {
362 return Err(ToolError(format!("{} already exists", args.path)));
363 }
364 WriteMode::Replace if !exists => {
365 return Err(ToolError(format!("{} does not exist", args.path)));
366 }
367 _ => {}
368 }
369 if let Some(expected) = args.expected_sha256 {
370 let mut current_file = root
371 .open(&path)
372 .map_err(|error| map_cap_error("hash", &args.path, error))?;
373 let mut current = Vec::new();
374 current_file
375 .read_to_end(&mut current)
376 .map_err(|error| ToolError(format!("hash {}: {error}", args.path)))?;
377 let actual = format!("{:x}", Sha256::digest(current));
378 if actual != expected.to_ascii_lowercase() {
379 return Err(ToolError(format!(
380 "{} changed: expected sha256 {}, found {}",
381 args.path, expected, actual
382 )));
383 }
384 }
385 let parent = path.parent().unwrap_or_else(|| Path::new("."));
386 root.create_dir_all(parent)
387 .map_err(|error| map_cap_error("create directory for", &args.path, error))?;
388 let temporary_path = unique_temporary_path(parent);
389 let mut options = OpenOptions::new();
390 options.write(true).create_new(true);
391 let mut temporary = root
392 .open_with(&temporary_path, &options)
393 .map_err(|error| map_cap_error("create temporary file for", &args.path, error))?;
394 let write_result = (|| {
395 temporary
396 .write_all(args.content.as_bytes())
397 .and_then(|_| temporary.sync_all())
398 .map_err(|error| ToolError(format!("write {}: {error}", args.path)))?;
399 if cancellation.is_cancelled() {
400 return Err(ToolError("write cancelled".into()));
401 }
402 match args.mode {
403 WriteMode::Create => root
404 .hard_link(&temporary_path, &root, &path)
405 .map_err(|error| map_cap_error("create", &args.path, error)),
406 WriteMode::Replace => root
407 .rename(&temporary_path, &root, &path)
408 .map_err(|error| map_cap_error("replace", &args.path, error)),
409 }
410 })();
411 if matches!(args.mode, WriteMode::Create) || write_result.is_err() {
412 let _ = root.remove_file(&temporary_path);
413 }
414 write_result?;
415 Ok(ToolOutput::success(
416 json!({
417 "path":args.path,
418 "bytes":args.content.len(),
419 "sha256":format!("{:x}", Sha256::digest(args.content.as_bytes()))
420 })
421 .to_string(),
422 ))
423 })
424 .await
425 .map_err(|error| ToolError(format!("write task failed: {error}")))?
426 }
427}
428
429struct BashTool {
430 timeout: Duration,
431 output_limit: usize,
432}
433
434#[derive(Deserialize)]
435#[serde(deny_unknown_fields)]
436struct BashArgs {
437 command: String,
438 timeout_seconds: Option<u64>,
439}
440
441#[async_trait]
442impl Tool for BashTool {
443 fn spec(&self) -> ToolSpec {
444 ToolSpec {
445 name: "bash".into(),
446 description: "Run a Bash command in the workspace (not sandboxed)".into(),
447 parameters: json!({
448 "type":"object",
449 "properties":{
450 "command":{"type":"string"},
451 "timeout_seconds":{"type":"integer","minimum":1}
452 },
453 "required":["command"],
454 "additionalProperties":false
455 }),
456 }
457 }
458
459 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
460 let args: BashArgs = parse_args(arguments)?;
461 validate_process_args(&args.command, args.timeout_seconds)?;
462 Ok(ToolRisk::Process)
463 }
464
465 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
466 let args: BashArgs = parse_args(arguments)?;
467 validate_process_args(&args.command, args.timeout_seconds)?;
468 Ok(format!(
469 "Run with /bin/bash -lc: {}",
470 bounded(&args.command, 2000)
471 ))
472 }
473
474 async fn execute(
475 &self,
476 arguments: Value,
477 context: ToolContext,
478 ) -> Result<ToolOutput, ToolError> {
479 let args: BashArgs = parse_args(&arguments)?;
480 validate_process_args(&args.command, args.timeout_seconds)?;
481 let requested = args
482 .timeout_seconds
483 .map(Duration::from_secs)
484 .unwrap_or(self.timeout)
485 .min(self.timeout);
486 execute_process(
487 ProcessSpec {
488 executable: OsString::from("/bin/bash"),
489 args: vec![OsString::from("-lc"), OsString::from(args.command)],
490 cwd: context.workspace,
491 timeout: requested,
492 output_limit: self.output_limit,
493 },
494 context.cancellation,
495 )
496 .await
497 }
498}
499
500struct NativeAgentTool {
501 name: String,
502 command: String,
503 resolved: Option<PathBuf>,
504 args: Vec<String>,
505 timeout: Duration,
506 output_limit: usize,
507}
508
509impl NativeAgentTool {
510 fn new(
511 name: String,
512 config: AgentAdapterConfig,
513 timeout: Duration,
514 output_limit: usize,
515 ) -> Self {
516 let resolved = which::which(&config.command).ok();
517 Self {
518 name,
519 command: config.command,
520 resolved,
521 args: config.args,
522 timeout,
523 output_limit,
524 }
525 }
526}
527
528#[derive(Deserialize)]
529#[serde(deny_unknown_fields)]
530struct AgentArgs {
531 prompt: String,
532 timeout_seconds: Option<u64>,
533}
534
535#[async_trait]
536impl Tool for NativeAgentTool {
537 fn spec(&self) -> ToolSpec {
538 ToolSpec {
539 name: self.name.clone(),
540 description: format!(
541 "Launch the configured {} CLI as a nested agent (not sandboxed)",
542 self.name
543 ),
544 parameters: json!({
545 "type":"object",
546 "properties":{
547 "prompt":{"type":"string"},
548 "timeout_seconds":{"type":"integer","minimum":1}
549 },
550 "required":["prompt"],
551 "additionalProperties":false
552 }),
553 }
554 }
555
556 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
557 let args: AgentArgs = parse_args(arguments)?;
558 validate_process_args(&args.prompt, args.timeout_seconds)?;
559 Ok(ToolRisk::Delegate)
560 }
561
562 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
563 let args: AgentArgs = parse_args(arguments)?;
564 validate_process_args(&args.prompt, args.timeout_seconds)?;
565 let executable = self.resolved.as_ref().map_or_else(
566 || self.command.as_str().into(),
567 |path| path.display().to_string(),
568 );
569 Ok(format!(
570 "Launch {executable} with fixed args {:?} and prompt {:?}. The nested agent has your user permissions.",
571 self.args,
572 bounded(&args.prompt, 2000)
573 ))
574 }
575
576 async fn execute(
577 &self,
578 arguments: Value,
579 context: ToolContext,
580 ) -> Result<ToolOutput, ToolError> {
581 let args: AgentArgs = parse_args(&arguments)?;
582 validate_process_args(&args.prompt, args.timeout_seconds)?;
583 let executable = self.resolved.as_ref().ok_or_else(|| {
584 ToolError(format!(
585 "{} executable {:?} was not found in PATH",
586 self.name, self.command
587 ))
588 })?;
589 let mut command_args: Vec<OsString> = self.args.iter().map(OsString::from).collect();
590 command_args.push(OsString::from(args.prompt));
591 let requested = args
592 .timeout_seconds
593 .map(Duration::from_secs)
594 .unwrap_or(self.timeout)
595 .min(self.timeout);
596 execute_process(
597 ProcessSpec {
598 executable: executable.as_os_str().to_owned(),
599 args: command_args,
600 cwd: context.workspace,
601 timeout: requested,
602 output_limit: self.output_limit,
603 },
604 context.cancellation,
605 )
606 .await
607 }
608}
609
610struct ProcessSpec {
611 executable: OsString,
612 args: Vec<OsString>,
613 cwd: PathBuf,
614 timeout: Duration,
615 output_limit: usize,
616}
617
618async fn execute_process(
619 spec: ProcessSpec,
620 cancellation: tokio_util::sync::CancellationToken,
621) -> Result<ToolOutput, ToolError> {
622 let deadline = Instant::now() + spec.timeout;
623 let mut command = Command::new(&spec.executable);
624 command
625 .args(&spec.args)
626 .current_dir(&spec.cwd)
627 .stdin(std::process::Stdio::null())
628 .stdout(std::process::Stdio::piped())
629 .stderr(std::process::Stdio::piped())
630 .kill_on_drop(true);
631 command.as_std_mut().process_group(0);
632 let mut child = command
633 .spawn()
634 .map_err(|error| ToolError(format!("launch {:?}: {error}", spec.executable)))?;
635 let pid = child
636 .id()
637 .ok_or_else(|| ToolError("child process has no pid".into()))? as i32;
638 let output = Arc::new(Mutex::new(BoundedOutput::new(spec.output_limit)));
639 let stdout_task = child.stdout.take().map(|stdout| {
640 let output = Arc::clone(&output);
641 tokio::spawn(drain_output(stdout, output))
642 });
643 let stderr_task = child.stderr.take().map(|stderr| {
644 let output = Arc::clone(&output);
645 tokio::spawn(drain_output(stderr, output))
646 });
647
648 enum Completion {
649 Exited(std::process::ExitStatus),
650 TimedOut,
651 Cancelled,
652 }
653 let completion = tokio::select! {
654 status = child.wait() => Completion::Exited(status.map_err(|error| ToolError(format!("wait for child: {error}")))?),
655 _ = cancellation.cancelled() => {
656 Completion::Cancelled
657 },
658 _ = sleep_until(deadline) => Completion::TimedOut,
659 };
660
661 let (status, timed_out, drain_deadline) = match completion {
662 Completion::Exited(status) => {
663 let cleanup_deadline = deadline.min(Instant::now() + Duration::from_secs(2));
664 let status =
665 terminate_group(pid, &mut child, Some(status), cleanup_deadline, true).await?;
666 (
667 status,
668 false,
669 deadline.min(Instant::now() + Duration::from_millis(250)),
670 )
671 }
672 Completion::TimedOut => {
673 let status = terminate_group(pid, &mut child, None, Instant::now(), false).await?;
674 (status, true, Instant::now() + Duration::from_millis(250))
675 }
676 Completion::Cancelled => {
677 let cleanup_deadline = Instant::now() + Duration::from_secs(2);
678 let _ = terminate_group(pid, &mut child, None, cleanup_deadline, true).await;
679 finish_drain(stdout_task, Instant::now() + Duration::from_millis(250)).await;
680 finish_drain(stderr_task, Instant::now() + Duration::from_millis(250)).await;
681 return Err(ToolError("process cancelled".into()));
682 }
683 };
684 finish_drain(stdout_task, drain_deadline).await;
685 finish_drain(stderr_task, drain_deadline).await;
686 let collected = output.lock().await;
687 let text = String::from_utf8_lossy(&collected.bytes).into_owned();
688 let content = json!({
689 "exit_code": status.code(),
690 "timed_out": timed_out,
691 "output": text,
692 "truncated": collected.truncated
693 })
694 .to_string();
695 Ok(ToolOutput {
696 content,
697 is_error: timed_out || !status.success(),
698 truncated: collected.truncated,
699 })
700}
701
702async fn terminate_group(
703 pid: i32,
704 child: &mut tokio::process::Child,
705 mut status: Option<std::process::ExitStatus>,
706 deadline: Instant,
707 graceful: bool,
708) -> Result<std::process::ExitStatus, ToolError> {
709 signal_group(
710 pid,
711 if graceful {
712 libc::SIGTERM
713 } else {
714 libc::SIGKILL
715 },
716 );
717 while Instant::now() < deadline {
718 if status.is_none() {
719 status = child
720 .try_wait()
721 .map_err(|error| ToolError(format!("wait for child: {error}")))?;
722 }
723 if !process_group_exists(pid)
724 && let Some(status) = status
725 {
726 return Ok(status);
727 }
728 sleep(Duration::from_millis(20)).await;
729 }
730 signal_group(pid, libc::SIGKILL);
732 if let Some(status) = status {
733 return Ok(status);
734 }
735 timeout(Duration::from_secs(1), child.wait())
736 .await
737 .map_err(|_| ToolError("child did not exit after process-group kill".into()))?
738 .map_err(|error| ToolError(format!("wait after KILL: {error}")))
739}
740
741fn signal_group(pid: i32, signal: i32) {
742 unsafe {
744 libc::kill(-pid, signal);
745 }
746}
747
748fn process_group_exists(pid: i32) -> bool {
749 let result = unsafe { libc::kill(-pid, 0) };
750 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
751}
752
753async fn finish_drain(task: Option<JoinHandle<()>>, deadline: Instant) {
754 let Some(mut task) = task else { return };
755 if timeout_at(deadline, &mut task).await.is_err() {
756 task.abort();
757 let _ = task.await;
758 }
759}
760
761async fn drain_output<R>(mut reader: R, output: Arc<Mutex<BoundedOutput>>)
762where
763 R: tokio::io::AsyncRead + Unpin,
764{
765 let mut chunk = [0u8; 8192];
766 loop {
767 match reader.read(&mut chunk).await {
768 Ok(0) | Err(_) => break,
769 Ok(read) => output.lock().await.push(&chunk[..read]),
770 }
771 }
772}
773
774struct BoundedOutput {
775 bytes: Vec<u8>,
776 limit: usize,
777 truncated: bool,
778}
779
780impl BoundedOutput {
781 fn new(limit: usize) -> Self {
782 Self {
783 bytes: Vec::with_capacity(limit.min(8192)),
784 limit,
785 truncated: false,
786 }
787 }
788
789 fn push(&mut self, bytes: &[u8]) {
790 let remaining = self.limit.saturating_sub(self.bytes.len());
791 self.bytes
792 .extend_from_slice(&bytes[..bytes.len().min(remaining)]);
793 self.truncated |= bytes.len() > remaining;
794 }
795}
796
797fn parse_args<T: for<'de> Deserialize<'de>>(value: &Value) -> Result<T, ToolError> {
798 serde_json::from_value(value.clone())
799 .map_err(|error| ToolError(format!("invalid arguments: {error}")))
800}
801
802fn validate_read_args(args: &ReadArgs) -> Result<(), ToolError> {
803 if args.limit == Some(0) {
804 return Err(ToolError("read limit must be positive".into()));
805 }
806 Ok(())
807}
808
809fn validate_process_args(value: &str, timeout_seconds: Option<u64>) -> Result<(), ToolError> {
810 if value.trim().is_empty() {
811 return Err(ToolError("command or prompt must be non-empty".into()));
812 }
813 if timeout_seconds == Some(0) {
814 return Err(ToolError("timeout_seconds must be positive".into()));
815 }
816 Ok(())
817}
818
819fn validate_relative(path: &Path) -> Result<(), ToolError> {
820 if path.as_os_str().is_empty() || path.is_absolute() {
821 return Err(ToolError("path must be non-empty and relative".into()));
822 }
823 for component in path.components() {
824 if matches!(
825 component,
826 Component::ParentDir | Component::RootDir | Component::Prefix(_)
827 ) {
828 return Err(ToolError(
829 "parent traversal and absolute paths are not allowed".into(),
830 ));
831 }
832 }
833 Ok(())
834}
835
836static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
837
838fn open_workspace(workspace: &Path) -> Result<Dir, ToolError> {
839 Dir::open_ambient_dir(workspace, ambient_authority())
840 .map_err(|error| ToolError(format!("open workspace capability: {error}")))
841}
842
843fn unique_temporary_path(parent: &Path) -> PathBuf {
844 let id = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
845 parent.join(format!(".peon-write-{}-{id}.tmp", std::process::id()))
846}
847
848fn map_cap_error(action: &str, path: &str, error: std::io::Error) -> ToolError {
849 ToolError(format!(
850 "{action} {path}: {error}; path must remain within workspace"
851 ))
852}
853
854fn is_secret_like(path: &Path) -> bool {
855 path.components().any(|component| {
856 let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
857 value == ".env"
858 || value.starts_with(".env.")
859 || value.contains("credential")
860 || value.contains("private_key")
861 || value.ends_with(".pem")
862 || value.ends_with(".key")
863 })
864}
865
866fn bounded(value: &str, max_chars: usize) -> String {
867 let mut output: String = value.chars().take(max_chars).collect();
868 if value.chars().count() > max_chars {
869 output.push('…');
870 }
871 output
872}
873
874#[cfg(test)]
875mod tests {
876 use std::os::unix::fs::{PermissionsExt, symlink};
877
878 use super::*;
879
880 #[test]
881 fn rejects_parent_traversal() {
882 assert!(validate_relative(Path::new("../secret")).is_err());
883 assert!(validate_relative(Path::new("/etc/passwd")).is_err());
884 }
885
886 #[test]
887 fn detects_secret_like_paths() {
888 assert!(is_secret_like(Path::new(".env")));
889 assert!(is_secret_like(Path::new("keys/id.pem")));
890 assert!(!is_secret_like(Path::new("src/main.rs")));
891 }
892
893 #[tokio::test]
894 async fn read_is_contained_and_bounded() {
895 let directory = tempfile::tempdir().unwrap();
896 std::fs::write(directory.path().join("hello.txt"), "abcdef").unwrap();
897 let tool = ReadTool { max_bytes: 3 };
898 let output = tool
899 .execute(
900 json!({"path":"hello.txt"}),
901 ToolContext {
902 workspace: directory.path().canonicalize().unwrap(),
903 cancellation: tokio_util::sync::CancellationToken::new(),
904 },
905 )
906 .await
907 .unwrap();
908 assert!(output.truncated);
909 assert!(output.content.contains("abc"));
910 }
911
912 #[tokio::test]
913 async fn read_rejects_symlink_escape() {
914 let workspace = tempfile::tempdir().unwrap();
915 let outside = tempfile::tempdir().unwrap();
916 std::fs::write(outside.path().join("secret"), "nope").unwrap();
917 symlink(outside.path(), workspace.path().join("escape")).unwrap();
918 let tool = ReadTool { max_bytes: 100 };
919 let result = tool
920 .execute(
921 json!({"path":"escape/secret"}),
922 ToolContext {
923 workspace: workspace.path().canonicalize().unwrap(),
924 cancellation: tokio_util::sync::CancellationToken::new(),
925 },
926 )
927 .await;
928 assert!(result.unwrap_err().to_string().contains("workspace"));
929 }
930
931 #[tokio::test]
932 async fn write_is_atomic_and_checks_hash() {
933 let workspace = tempfile::tempdir().unwrap();
934 let root = workspace.path().canonicalize().unwrap();
935 let tool = WriteTool { max_bytes: 100 };
936 tool.execute(
937 json!({"path":"file.txt","content":"first","mode":"create"}),
938 ToolContext {
939 workspace: root.clone(),
940 cancellation: tokio_util::sync::CancellationToken::new(),
941 },
942 )
943 .await
944 .unwrap();
945 let hash = format!("{:x}", Sha256::digest(b"first"));
946 tool.execute(
947 json!({"path":"file.txt","content":"second","mode":"replace","expected_sha256":hash}),
948 ToolContext {
949 workspace: root.clone(),
950 cancellation: tokio_util::sync::CancellationToken::new(),
951 },
952 )
953 .await
954 .unwrap();
955 assert_eq!(
956 std::fs::read_to_string(root.join("file.txt")).unwrap(),
957 "second"
958 );
959 let result = tool
960 .execute(
961 json!({"path":"file.txt","content":"third","mode":"replace","expected_sha256":"deadbeef"}),
962 ToolContext {
963 workspace: root,
964 cancellation: tokio_util::sync::CancellationToken::new(),
965 },
966 )
967 .await;
968 assert!(result.unwrap_err().to_string().contains("changed"));
969 }
970
971 #[tokio::test]
972 async fn write_rejects_symlink_escape() {
973 let workspace = tempfile::tempdir().unwrap();
974 let outside = tempfile::tempdir().unwrap();
975 symlink(outside.path(), workspace.path().join("escape")).unwrap();
976 let tool = WriteTool { max_bytes: 100 };
977 let result = tool
978 .execute(
979 json!({"path":"escape/file.txt","content":"nope","mode":"create"}),
980 ToolContext {
981 workspace: workspace.path().canonicalize().unwrap(),
982 cancellation: tokio_util::sync::CancellationToken::new(),
983 },
984 )
985 .await;
986 assert!(result.unwrap_err().to_string().contains("workspace"));
987 assert!(!outside.path().join("file.txt").exists());
988 }
989
990 #[tokio::test]
991 async fn bash_timeout_terminates_the_process() {
992 let workspace = tempfile::tempdir().unwrap();
993 let tool = BashTool {
994 timeout: Duration::from_millis(50),
995 output_limit: 100,
996 };
997 let started = std::time::Instant::now();
998 let output = tool
999 .execute(
1000 json!({"command":"sleep 5"}),
1001 ToolContext {
1002 workspace: workspace.path().canonicalize().unwrap(),
1003 cancellation: tokio_util::sync::CancellationToken::new(),
1004 },
1005 )
1006 .await
1007 .unwrap();
1008 assert!(output.is_error);
1009 assert!(started.elapsed() < Duration::from_secs(3));
1010 }
1011
1012 #[tokio::test]
1013 async fn bash_output_is_bounded_and_reports_truncation() {
1014 let workspace = tempfile::tempdir().unwrap();
1015 let tool = BashTool {
1016 timeout: Duration::from_secs(2),
1017 output_limit: 8,
1018 };
1019 let output = tool
1020 .execute(
1021 json!({"command":"printf 12345678901234567890"}),
1022 ToolContext {
1023 workspace: workspace.path().canonicalize().unwrap(),
1024 cancellation: tokio_util::sync::CancellationToken::new(),
1025 },
1026 )
1027 .await
1028 .unwrap();
1029 assert!(output.truncated);
1030 assert!(output.content.contains("12345678"));
1031 assert!(!output.content.contains("123456789"));
1032 }
1033
1034 #[tokio::test]
1035 async fn bash_cancellation_terminates_the_process_group() {
1036 let workspace = tempfile::tempdir().unwrap();
1037 let tool = BashTool {
1038 timeout: Duration::from_secs(30),
1039 output_limit: 100,
1040 };
1041 let cancellation = tokio_util::sync::CancellationToken::new();
1042 let cancel = cancellation.clone();
1043 let started = std::time::Instant::now();
1044 let execution = tokio::spawn(async move {
1045 tool.execute(
1046 json!({"command":"sleep 30"}),
1047 ToolContext {
1048 workspace: workspace.path().canonicalize().unwrap(),
1049 cancellation,
1050 },
1051 )
1052 .await
1053 });
1054 tokio::time::sleep(Duration::from_millis(50)).await;
1055 cancel.cancel();
1056 let error = execution.await.unwrap().unwrap_err();
1057 assert!(error.to_string().contains("cancelled"));
1058 assert!(started.elapsed() < Duration::from_secs(3));
1059 }
1060
1061 #[tokio::test]
1062 async fn background_descendant_cannot_hold_output_pipes_open() {
1063 let workspace = tempfile::tempdir().unwrap();
1064 let root = workspace.path().canonicalize().unwrap();
1065 let tool = BashTool {
1066 timeout: Duration::from_secs(5),
1067 output_limit: 100,
1068 };
1069 let started = std::time::Instant::now();
1070 let output = tool
1071 .execute(
1072 json!({"command":"sleep 30 & echo $! > background.pid; exit 0"}),
1073 ToolContext {
1074 workspace: root.clone(),
1075 cancellation: tokio_util::sync::CancellationToken::new(),
1076 },
1077 )
1078 .await
1079 .unwrap();
1080 assert!(!output.is_error);
1081 assert!(started.elapsed() < Duration::from_secs(3));
1082 let pid: i32 = std::fs::read_to_string(root.join("background.pid"))
1083 .unwrap()
1084 .trim()
1085 .parse()
1086 .unwrap();
1087 for _ in 0..20 {
1088 if unsafe { libc::kill(pid, 0) } != 0 {
1089 return;
1090 }
1091 tokio::time::sleep(Duration::from_millis(10)).await;
1092 }
1093 panic!("background descendant {pid} survived tool completion");
1094 }
1095
1096 #[tokio::test]
1097 async fn cancellation_kills_a_term_ignoring_descendant() {
1098 let workspace = tempfile::tempdir().unwrap();
1099 let root = workspace.path().canonicalize().unwrap();
1100 let tool = BashTool {
1101 timeout: Duration::from_secs(30),
1102 output_limit: 100,
1103 };
1104 let cancellation = tokio_util::sync::CancellationToken::new();
1105 let cancel = cancellation.clone();
1106 let command_root = root.clone();
1107 let execution = tokio::spawn(async move {
1108 tool.execute(
1109 json!({"command":"trap '' TERM; (trap '' TERM; sleep 30) & echo $! > stubborn.pid; wait"}),
1110 ToolContext {
1111 workspace: command_root,
1112 cancellation,
1113 },
1114 )
1115 .await
1116 });
1117 let pid_path = root.join("stubborn.pid");
1118 let mut descendant_pid = None;
1119 for _ in 0..100 {
1120 descendant_pid = std::fs::read_to_string(&pid_path)
1121 .ok()
1122 .and_then(|value| value.trim().parse::<i32>().ok());
1123 if descendant_pid.is_some() {
1124 break;
1125 }
1126 tokio::time::sleep(Duration::from_millis(10)).await;
1127 }
1128 let pid = descendant_pid.expect("command did not report its descendant pid");
1129 let started = std::time::Instant::now();
1130 cancel.cancel();
1131 let error = execution.await.unwrap().unwrap_err();
1132 assert!(error.to_string().contains("cancelled"));
1133 assert!(started.elapsed() < Duration::from_secs(3));
1134 for _ in 0..20 {
1135 if unsafe { libc::kill(pid, 0) } != 0 {
1136 return;
1137 }
1138 tokio::time::sleep(Duration::from_millis(10)).await;
1139 }
1140 panic!("TERM-ignoring descendant {pid} survived cancellation");
1141 }
1142
1143 #[tokio::test]
1144 async fn native_agent_preserves_argument_boundaries() {
1145 let workspace = tempfile::tempdir().unwrap();
1146 let executable = workspace.path().join("fake-agent");
1147 std::fs::write(&executable, "#!/bin/bash\npwd\nprintf '%s\\n' \"$@\"\n").unwrap();
1148 let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
1149 permissions.set_mode(0o755);
1150 std::fs::set_permissions(&executable, permissions).unwrap();
1151 let tool = NativeAgentTool::new(
1152 "agent_fake".into(),
1153 AgentAdapterConfig {
1154 command: executable.display().to_string(),
1155 args: vec!["--fixed".into()],
1156 },
1157 Duration::from_secs(2),
1158 1024,
1159 );
1160 let output = tool
1161 .execute(
1162 json!({"prompt":"hello; echo unsafe"}),
1163 ToolContext {
1164 workspace: workspace.path().canonicalize().unwrap(),
1165 cancellation: tokio_util::sync::CancellationToken::new(),
1166 },
1167 )
1168 .await
1169 .unwrap();
1170 assert!(output.content.contains("--fixed"));
1171 assert!(output.content.contains("hello; echo unsafe"));
1172 assert!(
1173 output
1174 .content
1175 .contains(&workspace.path().display().to_string())
1176 );
1177 }
1178}