Skip to main content

scv_tools/
lib.rs

1//! Peon's bounded, workspace-aware built-in tools.
2
3use 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    // Always finish the process group, even if its original leader already exited.
731    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    // Negative PID addresses the process group created at spawn.
743    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}