Skip to main content

scv_tools/
lib.rs

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