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    /// Default `bash` timeout when a call does not choose one.
36    pub command_timeout: Duration,
37    /// Default native-agent timeout when a call does not choose one.
38    pub agent_timeout: Duration,
39    /// The longest timeout any single call may request.
40    pub max_timeout: Duration,
41    pub output_limit_bytes: usize,
42    pub max_read_bytes: usize,
43    pub max_write_bytes: usize,
44}
45
46impl Default for ToolsConfig {
47    fn default() -> Self {
48        Self {
49            command_timeout: Duration::from_secs(120),
50            agent_timeout: Duration::from_secs(600),
51            max_timeout: Duration::from_secs(1800),
52            output_limit_bytes: 64 * 1024,
53            max_read_bytes: 256 * 1024,
54            max_write_bytes: 1024 * 1024,
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60pub struct AgentAdapterConfig {
61    pub command: String,
62    pub args: Vec<String>,
63    /// Arguments appended for a per-call model; `{model}` is substituted.
64    /// Empty means the adapter does not offer model selection.
65    pub model_args: Vec<String>,
66    /// Arguments appended for a per-call effort; `{effort}` is substituted.
67    /// Empty means the adapter does not offer effort selection.
68    pub effort_args: Vec<String>,
69    /// Environment for the nested process. SCV supplies an instance-private home.
70    pub environment: Vec<(OsString, OsString)>,
71}
72
73pub type SkillMap = HashMap<String, PathBuf>;
74
75pub fn builtin_registry(
76    config: ToolsConfig,
77    skills: SkillMap,
78    skill_roots: Vec<PathBuf>,
79    max_skill_bytes: usize,
80    adapters: HashMap<String, AgentAdapterConfig>,
81) -> Result<ToolRegistry, ToolError> {
82    let mut registry = ToolRegistry::default();
83    registry.register(Arc::new(ReadTool {
84        max_bytes: config.max_read_bytes,
85    }))?;
86    registry.register(Arc::new(ReadSkillTool {
87        skills,
88        roots: skill_roots,
89        max_bytes: max_skill_bytes,
90    }))?;
91    registry.register(Arc::new(WriteTool {
92        max_bytes: config.max_write_bytes,
93    }))?;
94    registry.register(Arc::new(BashTool {
95        timeout: config.command_timeout,
96        max_timeout: config.max_timeout,
97        output_limit: config.output_limit_bytes,
98    }))?;
99    for (name, adapter) in adapters {
100        registry.register(Arc::new(NativeAgentTool::new(
101            name,
102            adapter,
103            Timeouts {
104                default: config.agent_timeout,
105                max: config.max_timeout,
106            },
107            config.output_limit_bytes,
108        )))?;
109    }
110    Ok(registry)
111}
112
113struct ReadTool {
114    max_bytes: usize,
115}
116
117#[derive(Deserialize)]
118#[serde(deny_unknown_fields)]
119struct ReadArgs {
120    path: String,
121    #[serde(default)]
122    offset: usize,
123    limit: Option<usize>,
124}
125
126#[async_trait]
127impl Tool for ReadTool {
128    fn spec(&self) -> ToolSpec {
129        ToolSpec {
130            name: "read".into(),
131            description: "Read a bounded UTF-8 file inside the workspace".into(),
132            parameters: json!({
133                "type":"object",
134                "properties":{
135                    "path":{"type":"string"},
136                    "offset":{"type":"integer","minimum":0},
137                    "limit":{"type":"integer","minimum":1}
138                },
139                "required":["path"],
140                "additionalProperties":false
141            }),
142        }
143    }
144
145    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
146        let args: ReadArgs = parse_args(arguments)?;
147        validate_read_args(&args)?;
148        Ok(if is_secret_like(Path::new(&args.path)) {
149            ToolRisk::Filesystem
150        } else {
151            ToolRisk::ReadOnly
152        })
153    }
154
155    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
156        let args: ReadArgs = parse_args(arguments)?;
157        validate_read_args(&args)?;
158        Ok(format!("Read {}", args.path))
159    }
160
161    async fn execute(
162        &self,
163        arguments: Value,
164        context: ToolContext,
165    ) -> Result<ToolOutput, ToolError> {
166        let args: ReadArgs = parse_args(&arguments)?;
167        validate_read_args(&args)?;
168        let requested = args.limit.unwrap_or(self.max_bytes).min(self.max_bytes);
169        let offset = u64::try_from(args.offset).unwrap_or(u64::MAX);
170        let workspace = context.workspace.clone();
171        let display_path = args.path.clone();
172        let relative = PathBuf::from(&args.path);
173        validate_relative(&relative)?;
174        let read = tokio::task::spawn_blocking(move || {
175            let root = open_workspace(&workspace)?;
176            let mut file = root
177                .open(&relative)
178                .map_err(|error| map_cap_error("read", &display_path, error))?;
179            let total_bytes = file
180                .metadata()
181                .map_err(|error| ToolError(format!("stat {display_path}: {error}")))?
182                .len();
183            let start = offset.min(total_bytes);
184            std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(start))
185                .map_err(|error| ToolError(format!("seek {display_path}: {error}")))?;
186            let mut bytes = Vec::with_capacity(requested.min(8192));
187            std::io::Read::take(&mut file, u64::try_from(requested).unwrap_or(u64::MAX))
188                .read_to_end(&mut bytes)
189                .map_err(|error| ToolError(format!("read {display_path}: {error}")))?;
190            Ok::<_, ToolError>((bytes, total_bytes, start))
191        });
192        let (bytes, total_bytes, start) = tokio::select! {
193            result = read => result.map_err(|error| ToolError(format!("read task failed: {error}")))??,
194            _ = context.cancellation.cancelled() => return Err(ToolError("read cancelled".into())),
195        };
196        let content = std::str::from_utf8(&bytes)
197            .map_err(|_| ToolError(format!("selected range of {} is not UTF-8", args.path)))?;
198        let end = start.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
199        let truncated = start > 0 || end < total_bytes;
200        Ok(ToolOutput {
201            content: json!({
202                "path": args.path,
203                "content": content,
204                "total_bytes": total_bytes,
205                "offset": start,
206                "truncated": truncated
207            })
208            .to_string(),
209            is_error: false,
210            truncated,
211        })
212    }
213}
214
215struct ReadSkillTool {
216    skills: SkillMap,
217    roots: Vec<PathBuf>,
218    max_bytes: usize,
219}
220
221#[derive(Deserialize)]
222#[serde(deny_unknown_fields)]
223struct ReadSkillArgs {
224    name: String,
225}
226
227#[async_trait]
228impl Tool for ReadSkillTool {
229    fn spec(&self) -> ToolSpec {
230        ToolSpec {
231            name: "read_skill".into(),
232            description: "Load a discovered SCV skill by name".into(),
233            parameters: json!({
234                "type":"object",
235                "properties":{"name":{"type":"string"}},
236                "required":["name"],
237                "additionalProperties":false
238            }),
239        }
240    }
241
242    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
243        let _: ReadSkillArgs = parse_args(arguments)?;
244        Ok(ToolRisk::ReadOnly)
245    }
246
247    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
248        let args: ReadSkillArgs = parse_args(arguments)?;
249        Ok(format!("Load skill {}", args.name))
250    }
251
252    async fn execute(
253        &self,
254        arguments: Value,
255        context: ToolContext,
256    ) -> Result<ToolOutput, ToolError> {
257        let args: ReadSkillArgs = parse_args(&arguments)?;
258        let configured = self
259            .skills
260            .get(&args.name)
261            .ok_or_else(|| ToolError(format!("unknown skill: {}", args.name)))?;
262        let path = std::fs::canonicalize(configured)
263            .map_err(|error| ToolError(format!("load skill {}: {error}", args.name)))?;
264        if !self.roots.iter().any(|root| path.starts_with(root)) {
265            return Err(ToolError("skill path escaped its configured root".into()));
266        }
267        let max_bytes = self.max_bytes;
268        let skill_name = args.name.clone();
269        let bytes = tokio::select! {
270            result = tokio::task::spawn_blocking(move || {
271                let mut file = std::fs::File::open(&path)
272                    .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
273                let mut bytes = Vec::with_capacity(max_bytes.min(8192));
274                std::io::Read::take(
275                    &mut file,
276                    u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1),
277                )
278                .read_to_end(&mut bytes)
279                .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
280                Ok::<_, ToolError>(bytes)
281            }) => result.map_err(|error| ToolError(format!("skill read task failed: {error}")))??,
282            _ = context.cancellation.cancelled() => return Err(ToolError("skill read cancelled".into())),
283        };
284        let end = bytes.len().min(self.max_bytes);
285        let content = std::str::from_utf8(&bytes[..end])
286            .map_err(|_| ToolError("skill is not UTF-8".into()))?;
287        Ok(ToolOutput {
288            content: content.to_owned(),
289            is_error: false,
290            truncated: end < bytes.len(),
291        })
292    }
293}
294
295struct WriteTool {
296    max_bytes: usize,
297}
298
299#[derive(Deserialize)]
300#[serde(deny_unknown_fields)]
301struct WriteArgs {
302    path: String,
303    content: String,
304    mode: WriteMode,
305    expected_sha256: Option<String>,
306}
307
308#[derive(Deserialize)]
309#[serde(rename_all = "snake_case")]
310enum WriteMode {
311    Create,
312    Replace,
313}
314
315#[async_trait]
316impl Tool for WriteTool {
317    fn spec(&self) -> ToolSpec {
318        ToolSpec {
319            name: "write".into(),
320            description: "Atomically create or replace a UTF-8 file inside the workspace".into(),
321            parameters: json!({
322                "type":"object",
323                "properties":{
324                    "path":{"type":"string"},
325                    "content":{"type":"string"},
326                    "mode":{"type":"string","enum":["create","replace"]},
327                    "expected_sha256":{"type":"string"}
328                },
329                "required":["path","content","mode"],
330                "additionalProperties":false
331            }),
332        }
333    }
334
335    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
336        let _: WriteArgs = parse_args(arguments)?;
337        Ok(ToolRisk::Filesystem)
338    }
339
340    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
341        let args: WriteArgs = parse_args(arguments)?;
342        let mode = match args.mode {
343            WriteMode::Create => "Create",
344            WriteMode::Replace => "Replace",
345        };
346        Ok(format!(
347            "{mode} {} ({} bytes)",
348            args.path,
349            args.content.len()
350        ))
351    }
352
353    async fn execute(
354        &self,
355        arguments: Value,
356        context: ToolContext,
357    ) -> Result<ToolOutput, ToolError> {
358        let args: WriteArgs = parse_args(&arguments)?;
359        if args.content.len() > self.max_bytes {
360            return Err(ToolError(format!(
361                "write exceeds {} byte limit",
362                self.max_bytes
363            )));
364        }
365        let workspace = context.workspace.clone();
366        let cancellation = context.cancellation.clone();
367        tokio::task::spawn_blocking(move || {
368            if cancellation.is_cancelled() {
369                return Err(ToolError("write cancelled".into()));
370            }
371            let path = PathBuf::from(&args.path);
372            validate_relative(&path)?;
373            let root = open_workspace(&workspace)?;
374            let exists = match root.symlink_metadata(&path) {
375                Ok(_) => true,
376                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
377                Err(error) => return Err(map_cap_error("inspect", &args.path, error)),
378            };
379            match args.mode {
380                WriteMode::Create if exists => {
381                    return Err(ToolError(format!("{} already exists", args.path)));
382                }
383                WriteMode::Replace if !exists => {
384                    return Err(ToolError(format!("{} does not exist", args.path)));
385                }
386                _ => {}
387            }
388            if let Some(expected) = args.expected_sha256 {
389                let mut current_file = root
390                    .open(&path)
391                    .map_err(|error| map_cap_error("hash", &args.path, error))?;
392                let mut current = Vec::new();
393                current_file
394                    .read_to_end(&mut current)
395                    .map_err(|error| ToolError(format!("hash {}: {error}", args.path)))?;
396                let actual = format!("{:x}", Sha256::digest(current));
397                if actual != expected.to_ascii_lowercase() {
398                    return Err(ToolError(format!(
399                        "{} changed: expected sha256 {}, found {}",
400                        args.path, expected, actual
401                    )));
402                }
403            }
404            let parent = path.parent().unwrap_or_else(|| Path::new("."));
405            root.create_dir_all(parent)
406                .map_err(|error| map_cap_error("create directory for", &args.path, error))?;
407            let temporary_path = unique_temporary_path(parent);
408            let mut options = OpenOptions::new();
409            options.write(true).create_new(true);
410            let mut temporary = root
411                .open_with(&temporary_path, &options)
412                .map_err(|error| map_cap_error("create temporary file for", &args.path, error))?;
413            let write_result = (|| {
414                temporary
415                    .write_all(args.content.as_bytes())
416                    .and_then(|_| temporary.sync_all())
417                    .map_err(|error| ToolError(format!("write {}: {error}", args.path)))?;
418                if cancellation.is_cancelled() {
419                    return Err(ToolError("write cancelled".into()));
420                }
421                match args.mode {
422                    WriteMode::Create => root
423                        .hard_link(&temporary_path, &root, &path)
424                        .map_err(|error| map_cap_error("create", &args.path, error)),
425                    WriteMode::Replace => root
426                        .rename(&temporary_path, &root, &path)
427                        .map_err(|error| map_cap_error("replace", &args.path, error)),
428                }
429            })();
430            if matches!(args.mode, WriteMode::Create) || write_result.is_err() {
431                let _ = root.remove_file(&temporary_path);
432            }
433            write_result?;
434            Ok(ToolOutput::success(
435                json!({
436                    "path":args.path,
437                    "bytes":args.content.len(),
438                    "sha256":format!("{:x}", Sha256::digest(args.content.as_bytes()))
439                })
440                .to_string(),
441            ))
442        })
443        .await
444        .map_err(|error| ToolError(format!("write task failed: {error}")))?
445    }
446}
447
448struct BashTool {
449    timeout: Duration,
450    max_timeout: Duration,
451    output_limit: usize,
452}
453
454impl BashTool {
455    fn timeouts(&self) -> Timeouts {
456        Timeouts {
457            default: self.timeout,
458            max: self.max_timeout,
459        }
460    }
461}
462
463/// A process tool's default timeout and the ceiling a call may raise it to.
464#[derive(Debug, Clone, Copy)]
465struct Timeouts {
466    default: Duration,
467    max: Duration,
468}
469
470impl Timeouts {
471    /// The call's timeout: its own request up to the ceiling, else the
472    /// default. A request above the ceiling is refused, never clamped, so the
473    /// caller learns the limit instead of being cut off early.
474    fn resolve(self, requested: Option<u64>) -> Result<Duration, ToolError> {
475        match requested {
476            None => Ok(self.default.min(self.max)),
477            Some(0) => Err(ToolError("timeout_seconds must be positive".into())),
478            Some(seconds) if seconds > self.max.as_secs() => Err(ToolError(format!(
479                "timeout_seconds {seconds} exceeds the configured maximum of {} seconds \
480                 (tools.max_timeout_seconds)",
481                self.max.as_secs()
482            ))),
483            Some(seconds) => Ok(Duration::from_secs(seconds)),
484        }
485    }
486}
487
488fn timeout_schema(timeouts: Timeouts) -> Value {
489    json!({
490        "type":"integer",
491        "minimum":1,
492        "maximum":timeouts.max.as_secs(),
493        "description":format!(
494            "Seconds before the process is killed. Defaults to {}; at most {}. \
495             Raise it for long work such as builds, releases, or landing a change.",
496            timeouts.default.min(timeouts.max).as_secs(),
497            timeouts.max.as_secs()
498        )
499    })
500}
501
502#[derive(Deserialize)]
503#[serde(deny_unknown_fields)]
504struct BashArgs {
505    command: String,
506    timeout_seconds: Option<u64>,
507}
508
509#[async_trait]
510impl Tool for BashTool {
511    fn spec(&self) -> ToolSpec {
512        ToolSpec {
513            name: "bash".into(),
514            description: "Run a Bash command in the workspace (not sandboxed)".into(),
515            parameters: json!({
516                "type":"object",
517                "properties":{
518                    "command":{"type":"string"},
519                    "timeout_seconds":timeout_schema(self.timeouts())
520                },
521                "required":["command"],
522                "additionalProperties":false
523            }),
524        }
525    }
526
527    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
528        let args: BashArgs = parse_args(arguments)?;
529        validate_process_args(&args.command)?;
530        self.timeouts().resolve(args.timeout_seconds)?;
531        Ok(ToolRisk::Process)
532    }
533
534    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
535        let args: BashArgs = parse_args(arguments)?;
536        validate_process_args(&args.command)?;
537        self.timeouts().resolve(args.timeout_seconds)?;
538        Ok(format!(
539            "Run with /bin/bash -lc: {}",
540            bounded(&args.command, 2000)
541        ))
542    }
543
544    async fn execute(
545        &self,
546        arguments: Value,
547        context: ToolContext,
548    ) -> Result<ToolOutput, ToolError> {
549        let args: BashArgs = parse_args(&arguments)?;
550        validate_process_args(&args.command)?;
551        let requested = self.timeouts().resolve(args.timeout_seconds)?;
552        execute_process(
553            ProcessSpec {
554                executable: OsString::from("/bin/bash"),
555                args: vec![OsString::from("-lc"), OsString::from(args.command)],
556                cwd: context.workspace,
557                environment: Vec::new(),
558                sanitize_scv_environment: false,
559                timeout: requested,
560                output_limit: self.output_limit,
561            },
562            context.cancellation,
563        )
564        .await
565    }
566}
567
568struct NativeAgentTool {
569    name: String,
570    command: String,
571    resolved: Option<PathBuf>,
572    args: Vec<String>,
573    model_args: Vec<String>,
574    effort_args: Vec<String>,
575    environment: Vec<(OsString, OsString)>,
576    timeouts: Timeouts,
577    output_limit: usize,
578}
579
580/// Effort levels accepted by the built-in adapters' CLIs.
581const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
582
583impl NativeAgentTool {
584    /// The fixed arguments plus validated model and effort selections; the
585    /// prompt is appended separately as the final argument.
586    fn command_args(&self, args: &AgentArgs) -> Result<Vec<String>, ToolError> {
587        validate_process_args(&args.prompt)?;
588        self.timeouts.resolve(args.timeout_seconds)?;
589        if let Some(cwd) = &args.cwd {
590            validate_agent_cwd(cwd)?;
591        }
592        // The prompt follows the flags as a positional argument, so it must
593        // not be readable as one.
594        if args.prompt.starts_with('-') {
595            return Err(ToolError("agent prompt must not start with '-'".into()));
596        }
597        let mut command = self.args.clone();
598        for (field, value, template, placeholder) in [
599            ("model", &args.model, &self.model_args, "{model}"),
600            ("effort", &args.effort, &self.effort_args, "{effort}"),
601        ] {
602            let Some(value) = value else {
603                continue;
604            };
605            if template.is_empty() {
606                return Err(ToolError(format!(
607                    "{} does not support selecting a {field}",
608                    self.name
609                )));
610            }
611            let valid = if field == "model" {
612                valid_model_name(value)
613            } else {
614                AGENT_EFFORTS.contains(&value.as_str())
615            };
616            if !valid {
617                return Err(ToolError(format!("invalid {field} {value:?}")));
618            }
619            command.extend(template.iter().map(|part| part.replace(placeholder, value)));
620        }
621        Ok(command)
622    }
623    fn new(
624        name: String,
625        config: AgentAdapterConfig,
626        timeouts: Timeouts,
627        output_limit: usize,
628    ) -> Self {
629        let resolved = which::which(&config.command).ok();
630        Self {
631            name,
632            command: config.command,
633            resolved,
634            args: config.args,
635            model_args: config.model_args,
636            effort_args: config.effort_args,
637            environment: config.environment,
638            timeouts,
639            output_limit,
640        }
641    }
642}
643
644#[derive(Deserialize)]
645#[serde(deny_unknown_fields)]
646struct AgentArgs {
647    prompt: String,
648    timeout_seconds: Option<u64>,
649    #[serde(default, deserialize_with = "blank_as_none")]
650    cwd: Option<String>,
651    #[serde(default, deserialize_with = "blank_as_none")]
652    model: Option<String>,
653    #[serde(default, deserialize_with = "blank_as_none")]
654    effort: Option<String>,
655}
656
657/// Models often send an optional string they mean to leave unset as `""`, so
658/// a blank value selects the default rather than failing the call.
659fn blank_as_none<'de, D: serde::Deserializer<'de>>(
660    deserializer: D,
661) -> Result<Option<String>, D::Error> {
662    let value = Option::<String>::deserialize(deserializer)?;
663    Ok(value.filter(|value| !value.trim().is_empty()))
664}
665
666/// Longest `cwd` argument accepted, in bytes.
667const MAX_AGENT_CWD_BYTES: usize = 4096;
668
669fn validate_agent_cwd(cwd: &str) -> Result<(), ToolError> {
670    if cwd.trim().is_empty() || cwd.len() > MAX_AGENT_CWD_BYTES || cwd.contains('\0') {
671        return Err(ToolError(format!(
672            "cwd must be a non-empty directory path of at most {MAX_AGENT_CWD_BYTES} bytes"
673        )));
674    }
675    Ok(())
676}
677
678/// Resolve a requested agent directory against the workspace. Resolution
679/// follows symlinks, so a link pointing outside the workspace is refused
680/// rather than trusted by name.
681fn resolve_agent_cwd(workspace: &Path, cwd: Option<&str>) -> Result<PathBuf, ToolError> {
682    let root = std::fs::canonicalize(workspace)
683        .map_err(|error| ToolError(format!("resolve workspace: {error}")))?;
684    let Some(cwd) = cwd else {
685        return Ok(root);
686    };
687    validate_agent_cwd(cwd)?;
688    let resolved = std::fs::canonicalize(root.join(cwd))
689        .map_err(|error| ToolError(format!("cwd {cwd:?}: {error}")))?;
690    if !resolved.starts_with(&root) {
691        return Err(ToolError(format!("cwd {cwd:?} is outside the workspace")));
692    }
693    if !resolved.is_dir() {
694        return Err(ToolError(format!("cwd {cwd:?} is not a directory")));
695    }
696    Ok(resolved)
697}
698
699/// Model names are passed as one argument, so only reject values that could
700/// read as a flag, name an `@file` argument, or carry unexpected characters.
701fn valid_model_name(value: &str) -> bool {
702    !value.is_empty()
703        && value.len() <= 128
704        && !value.starts_with(['-', '@'])
705        && value
706            .chars()
707            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
708}
709
710#[async_trait]
711impl Tool for NativeAgentTool {
712    fn spec(&self) -> ToolSpec {
713        let mut properties = json!({
714            "prompt":{"type":"string"},
715            "cwd":{
716                "type":"string",
717                "description":"Directory inside the workspace to run in, such as a project directory (\"scv\"). \
718                    The agent loads that directory's AGENTS.md or CLAUDE.md and its project skills. \
719                    Defaults to the workspace root."
720            },
721            "timeout_seconds":timeout_schema(self.timeouts)
722        });
723        if !self.model_args.is_empty() {
724            let model = match self.name.as_str() {
725                "agent_claude" => "Claude model alias or ID, such as sonnet or opus.",
726                "agent_codex" => {
727                    "OpenAI model ID from the Codex configuration; not a Claude alias."
728                }
729                _ => "Model ID in the form this agent's CLI accepts.",
730            };
731            properties["model"] = json!({
732                "type":"string",
733                "description":format!(
734                    "{model} Set only when the user asks for a specific model; \
735                     omit to use the agent's configured default."
736                )
737            });
738        }
739        if !self.effort_args.is_empty() {
740            properties["effort"] = json!({
741                "type":"string",
742                "enum":AGENT_EFFORTS,
743                "description":"Reasoning effort. Set only when the user asks for one; \
744                    omit to use the agent's configured default."
745            });
746        }
747        ToolSpec {
748            name: self.name.clone(),
749            description: format!(
750                "Launch the configured {} CLI as a nested agent (not sandboxed). \
751                 Set cwd to the project the work is in so the agent follows that \
752                 project's instructions and skills.",
753                self.name
754            ),
755            parameters: json!({
756                "type":"object",
757                "properties":properties,
758                "required":["prompt"],
759                "additionalProperties":false
760            }),
761        }
762    }
763
764    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
765        let args: AgentArgs = parse_args(arguments)?;
766        self.command_args(&args)?;
767        Ok(ToolRisk::Delegate)
768    }
769
770    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
771        let args: AgentArgs = parse_args(arguments)?;
772        let command_args = self.command_args(&args)?;
773        let executable = self.resolved.as_ref().map_or_else(
774            || self.command.as_str().into(),
775            |path| path.display().to_string(),
776        );
777        let directory = args.cwd.as_deref().map_or_else(
778            || "the workspace root".to_owned(),
779            |cwd| format!("{:?} (inside the workspace)", bounded(cwd, 200)),
780        );
781        let timeout = self.timeouts.resolve(args.timeout_seconds)?;
782        Ok(format!(
783            "Launch {executable} with args {command_args:?} and prompt {:?} in {directory} for up to {} seconds. The nested agent has your user permissions.",
784            bounded(&args.prompt, 2000),
785            timeout.as_secs()
786        ))
787    }
788
789    async fn execute(
790        &self,
791        arguments: Value,
792        context: ToolContext,
793    ) -> Result<ToolOutput, ToolError> {
794        let args: AgentArgs = parse_args(&arguments)?;
795        let command_args = self.command_args(&args)?;
796        let cwd = resolve_agent_cwd(&context.workspace, args.cwd.as_deref())?;
797        let executable = self.resolved.as_ref().ok_or_else(|| {
798            ToolError(format!(
799                "{} executable {:?} was not found in PATH",
800                self.name, self.command
801            ))
802        })?;
803        let mut command_args: Vec<OsString> =
804            command_args.into_iter().map(OsString::from).collect();
805        command_args.push(OsString::from(args.prompt));
806        let requested = self.timeouts.resolve(args.timeout_seconds)?;
807        let mut output = execute_process(
808            ProcessSpec {
809                executable: executable.as_os_str().to_owned(),
810                args: command_args,
811                cwd,
812                environment: self.environment.clone(),
813                sanitize_scv_environment: true,
814                timeout: requested,
815                output_limit: self.output_limit,
816            },
817            context.cancellation,
818        )
819        .await?;
820        if output.is_error {
821            add_sign_in_hint(&mut output, self.name.trim_start_matches("agent_"));
822        }
823        Ok(output)
824    }
825}
826
827/// Variables removed from every native agent's environment, so an agent
828/// signs in only with credentials stored in its SCV-private home and never
829/// inherits SCV's provider settings or a config location outside that home.
830pub const AGENT_REMOVED_ENVIRONMENT: &[&str] = &[
831    "SCV_CONFIG",
832    "SCV_MODEL",
833    "SCV_PROVIDER",
834    "SCV_BASE_URL",
835    "SCV_API_KEY_ENV",
836    "OPENAI_API_KEY",
837    "OPENAI_BASE_URL",
838    "OPENAI_ORG_ID",
839    "OPENAI_PROJECT_ID",
840    "CODEX_API_KEY",
841    "CODEX_BASE_URL",
842    "ANTHROPIC_API_KEY",
843    "ANTHROPIC_BASE_URL",
844    "ANTHROPIC_AUTH_TOKEN",
845    "CLAUDE_CODE_OAUTH_TOKEN",
846    "CLAUDE_CONFIG_DIR",
847    "GEMINI_API_KEY",
848    "GOOGLE_API_KEY",
849    "AZURE_OPENAI_API_KEY",
850    "AZURE_OPENAI_ENDPOINT",
851];
852
853/// Point a failed agent run that reads like a missing sign-in at the host
854/// command that fixes it, since the agent's own advice (`/login`) cannot be
855/// followed from a remote chat.
856fn add_sign_in_hint(output: &mut ToolOutput, agent: &str) {
857    let lower = output.content.to_ascii_lowercase();
858    let unauthenticated = [
859        "not logged in",
860        "/login",
861        "codex login",
862        "log in",
863        "unauthorized",
864        "authentication",
865    ]
866    .iter()
867    .any(|needle| lower.contains(needle));
868    if !unauthenticated {
869        return;
870    }
871    if let Ok(Value::Object(mut content)) = serde_json::from_str::<Value>(&output.content) {
872        content.insert(
873            "hint".into(),
874            format!(
875                "The {agent} CLI appears to be signed out of SCV's private agent home. \
876                 The host owner can sign it in with: scv agents login {agent}"
877            )
878            .into(),
879        );
880        output.content = Value::Object(content).to_string();
881    }
882}
883
884struct ProcessSpec {
885    executable: OsString,
886    args: Vec<OsString>,
887    cwd: PathBuf,
888    environment: Vec<(OsString, OsString)>,
889    sanitize_scv_environment: bool,
890    timeout: Duration,
891    output_limit: usize,
892}
893
894async fn execute_process(
895    spec: ProcessSpec,
896    cancellation: tokio_util::sync::CancellationToken,
897) -> Result<ToolOutput, ToolError> {
898    let deadline = Instant::now() + spec.timeout;
899    let mut command = Command::new(&spec.executable);
900    command
901        .args(&spec.args)
902        .current_dir(&spec.cwd)
903        .envs(spec.environment)
904        .stdin(std::process::Stdio::null())
905        .stdout(std::process::Stdio::piped())
906        .stderr(std::process::Stdio::piped())
907        .kill_on_drop(true);
908    if spec.sanitize_scv_environment {
909        for variable in AGENT_REMOVED_ENVIRONMENT {
910            command.env_remove(variable);
911        }
912    }
913    command.as_std_mut().process_group(0);
914    let mut child = command
915        .spawn()
916        .map_err(|error| ToolError(format!("launch {:?}: {error}", spec.executable)))?;
917    let pid = child
918        .id()
919        .ok_or_else(|| ToolError("child process has no pid".into()))? as i32;
920    let output = Arc::new(Mutex::new(BoundedOutput::new(spec.output_limit)));
921    let stdout_task = child.stdout.take().map(|stdout| {
922        let output = Arc::clone(&output);
923        tokio::spawn(drain_output(stdout, output))
924    });
925    let stderr_task = child.stderr.take().map(|stderr| {
926        let output = Arc::clone(&output);
927        tokio::spawn(drain_output(stderr, output))
928    });
929
930    enum Completion {
931        Exited(std::process::ExitStatus),
932        TimedOut,
933        Cancelled,
934    }
935    let completion = tokio::select! {
936        status = child.wait() => Completion::Exited(status.map_err(|error| ToolError(format!("wait for child: {error}")))?),
937        _ = cancellation.cancelled() => {
938            Completion::Cancelled
939        },
940        _ = sleep_until(deadline) => Completion::TimedOut,
941    };
942
943    let (status, timed_out, drain_deadline) = match completion {
944        Completion::Exited(status) => {
945            let cleanup_deadline = deadline.min(Instant::now() + Duration::from_secs(2));
946            let status =
947                terminate_group(pid, &mut child, Some(status), cleanup_deadline, true).await?;
948            (
949                status,
950                false,
951                deadline.min(Instant::now() + Duration::from_millis(250)),
952            )
953        }
954        Completion::TimedOut => {
955            let status = terminate_group(pid, &mut child, None, Instant::now(), false).await?;
956            (status, true, Instant::now() + Duration::from_millis(250))
957        }
958        Completion::Cancelled => {
959            let cleanup_deadline = Instant::now() + Duration::from_secs(2);
960            let _ = terminate_group(pid, &mut child, None, cleanup_deadline, true).await;
961            finish_drain(stdout_task, Instant::now() + Duration::from_millis(250)).await;
962            finish_drain(stderr_task, Instant::now() + Duration::from_millis(250)).await;
963            return Err(ToolError("process cancelled".into()));
964        }
965    };
966    finish_drain(stdout_task, drain_deadline).await;
967    finish_drain(stderr_task, drain_deadline).await;
968    let collected = output.lock().await;
969    let text = String::from_utf8_lossy(&collected.bytes).into_owned();
970    let content = json!({
971        "exit_code": status.code(),
972        "timed_out": timed_out,
973        "output": text,
974        "truncated": collected.truncated
975    })
976    .to_string();
977    Ok(ToolOutput {
978        content,
979        is_error: timed_out || !status.success(),
980        truncated: collected.truncated,
981    })
982}
983
984async fn terminate_group(
985    pid: i32,
986    child: &mut tokio::process::Child,
987    mut status: Option<std::process::ExitStatus>,
988    deadline: Instant,
989    graceful: bool,
990) -> Result<std::process::ExitStatus, ToolError> {
991    signal_group(
992        pid,
993        if graceful {
994            libc::SIGTERM
995        } else {
996            libc::SIGKILL
997        },
998    );
999    while Instant::now() < deadline {
1000        if status.is_none() {
1001            status = child
1002                .try_wait()
1003                .map_err(|error| ToolError(format!("wait for child: {error}")))?;
1004        }
1005        if !process_group_exists(pid)
1006            && let Some(status) = status
1007        {
1008            return Ok(status);
1009        }
1010        sleep(Duration::from_millis(20)).await;
1011    }
1012    // Always finish the process group, even if its original leader already exited.
1013    signal_group(pid, libc::SIGKILL);
1014    if let Some(status) = status {
1015        return Ok(status);
1016    }
1017    timeout(Duration::from_secs(1), child.wait())
1018        .await
1019        .map_err(|_| ToolError("child did not exit after process-group kill".into()))?
1020        .map_err(|error| ToolError(format!("wait after KILL: {error}")))
1021}
1022
1023fn signal_group(pid: i32, signal: i32) {
1024    // Negative PID addresses the process group created at spawn.
1025    unsafe {
1026        libc::kill(-pid, signal);
1027    }
1028}
1029
1030fn process_group_exists(pid: i32) -> bool {
1031    let result = unsafe { libc::kill(-pid, 0) };
1032    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1033}
1034
1035async fn finish_drain(task: Option<JoinHandle<()>>, deadline: Instant) {
1036    let Some(mut task) = task else { return };
1037    if timeout_at(deadline, &mut task).await.is_err() {
1038        task.abort();
1039        let _ = task.await;
1040    }
1041}
1042
1043async fn drain_output<R>(mut reader: R, output: Arc<Mutex<BoundedOutput>>)
1044where
1045    R: tokio::io::AsyncRead + Unpin,
1046{
1047    let mut chunk = [0u8; 8192];
1048    loop {
1049        match reader.read(&mut chunk).await {
1050            Ok(0) | Err(_) => break,
1051            Ok(read) => output.lock().await.push(&chunk[..read]),
1052        }
1053    }
1054}
1055
1056struct BoundedOutput {
1057    bytes: Vec<u8>,
1058    limit: usize,
1059    truncated: bool,
1060}
1061
1062impl BoundedOutput {
1063    fn new(limit: usize) -> Self {
1064        Self {
1065            bytes: Vec::with_capacity(limit.min(8192)),
1066            limit,
1067            truncated: false,
1068        }
1069    }
1070
1071    fn push(&mut self, bytes: &[u8]) {
1072        let remaining = self.limit.saturating_sub(self.bytes.len());
1073        self.bytes
1074            .extend_from_slice(&bytes[..bytes.len().min(remaining)]);
1075        self.truncated |= bytes.len() > remaining;
1076    }
1077}
1078
1079fn parse_args<T: for<'de> Deserialize<'de>>(value: &Value) -> Result<T, ToolError> {
1080    serde_json::from_value(value.clone())
1081        .map_err(|error| ToolError(format!("invalid arguments: {error}")))
1082}
1083
1084fn validate_read_args(args: &ReadArgs) -> Result<(), ToolError> {
1085    if args.limit == Some(0) {
1086        return Err(ToolError("read limit must be positive".into()));
1087    }
1088    Ok(())
1089}
1090
1091fn validate_process_args(value: &str) -> Result<(), ToolError> {
1092    if value.trim().is_empty() {
1093        return Err(ToolError("command or prompt must be non-empty".into()));
1094    }
1095    Ok(())
1096}
1097
1098fn validate_relative(path: &Path) -> Result<(), ToolError> {
1099    if path.as_os_str().is_empty() || path.is_absolute() {
1100        return Err(ToolError("path must be non-empty and relative".into()));
1101    }
1102    for component in path.components() {
1103        if matches!(
1104            component,
1105            Component::ParentDir | Component::RootDir | Component::Prefix(_)
1106        ) {
1107            return Err(ToolError(
1108                "parent traversal and absolute paths are not allowed".into(),
1109            ));
1110        }
1111    }
1112    Ok(())
1113}
1114
1115static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
1116
1117fn open_workspace(workspace: &Path) -> Result<Dir, ToolError> {
1118    Dir::open_ambient_dir(workspace, ambient_authority())
1119        .map_err(|error| ToolError(format!("open workspace capability: {error}")))
1120}
1121
1122fn unique_temporary_path(parent: &Path) -> PathBuf {
1123    let id = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
1124    parent.join(format!(".scv-write-{}-{id}.tmp", std::process::id()))
1125}
1126
1127fn map_cap_error(action: &str, path: &str, error: std::io::Error) -> ToolError {
1128    ToolError(format!(
1129        "{action} {path}: {error}; path must remain within workspace"
1130    ))
1131}
1132
1133fn is_secret_like(path: &Path) -> bool {
1134    path.components().any(|component| {
1135        let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
1136        value == ".env"
1137            || value.starts_with(".env.")
1138            || value.contains("credential")
1139            || value.contains("private_key")
1140            || value.ends_with(".pem")
1141            || value.ends_with(".key")
1142    })
1143}
1144
1145fn bounded(value: &str, max_chars: usize) -> String {
1146    let mut output: String = value.chars().take(max_chars).collect();
1147    if value.chars().count() > max_chars {
1148        output.push('โ€ฆ');
1149    }
1150    output
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use std::os::unix::fs::symlink;
1156
1157    use super::*;
1158
1159    /// `bash -l` sources the host's login profile before it runs a command,
1160    /// and CI images can spend seconds there under parallel test load. Waits
1161    /// that include shell startup use this ceiling; they end as soon as their
1162    /// condition holds.
1163    const SHELL_STARTUP: Duration = Duration::from_secs(30);
1164
1165    /// Polls `probe` every 10 ms until it yields a value or `limit` passes.
1166    async fn wait_for<T>(limit: Duration, mut probe: impl FnMut() -> Option<T>) -> Option<T> {
1167        let deadline = std::time::Instant::now() + limit;
1168        loop {
1169            if let Some(value) = probe() {
1170                return Some(value);
1171            }
1172            if std::time::Instant::now() >= deadline {
1173                return None;
1174            }
1175            tokio::time::sleep(Duration::from_millis(10)).await;
1176        }
1177    }
1178
1179    fn is_gone(pid: i32) -> Option<()> {
1180        (unsafe { libc::kill(pid, 0) } != 0).then_some(())
1181    }
1182
1183    #[test]
1184    fn rejects_parent_traversal() {
1185        assert!(validate_relative(Path::new("../secret")).is_err());
1186        assert!(validate_relative(Path::new("/etc/passwd")).is_err());
1187    }
1188
1189    #[test]
1190    fn detects_secret_like_paths() {
1191        assert!(is_secret_like(Path::new(".env")));
1192        assert!(is_secret_like(Path::new("keys/id.pem")));
1193        assert!(!is_secret_like(Path::new("src/main.rs")));
1194    }
1195
1196    #[tokio::test]
1197    async fn read_is_contained_and_bounded() {
1198        let directory = tempfile::tempdir().unwrap();
1199        std::fs::write(directory.path().join("hello.txt"), "abcdef").unwrap();
1200        let tool = ReadTool { max_bytes: 3 };
1201        let output = tool
1202            .execute(
1203                json!({"path":"hello.txt"}),
1204                ToolContext {
1205                    workspace: directory.path().canonicalize().unwrap(),
1206                    cancellation: tokio_util::sync::CancellationToken::new(),
1207                },
1208            )
1209            .await
1210            .unwrap();
1211        assert!(output.truncated);
1212        assert!(output.content.contains("abc"));
1213    }
1214
1215    #[tokio::test]
1216    async fn read_rejects_symlink_escape() {
1217        let workspace = tempfile::tempdir().unwrap();
1218        let outside = tempfile::tempdir().unwrap();
1219        std::fs::write(outside.path().join("secret"), "nope").unwrap();
1220        symlink(outside.path(), workspace.path().join("escape")).unwrap();
1221        let tool = ReadTool { max_bytes: 100 };
1222        let result = tool
1223            .execute(
1224                json!({"path":"escape/secret"}),
1225                ToolContext {
1226                    workspace: workspace.path().canonicalize().unwrap(),
1227                    cancellation: tokio_util::sync::CancellationToken::new(),
1228                },
1229            )
1230            .await;
1231        assert!(result.unwrap_err().to_string().contains("workspace"));
1232    }
1233
1234    #[tokio::test]
1235    async fn write_is_atomic_and_checks_hash() {
1236        let workspace = tempfile::tempdir().unwrap();
1237        let root = workspace.path().canonicalize().unwrap();
1238        let tool = WriteTool { max_bytes: 100 };
1239        tool.execute(
1240            json!({"path":"file.txt","content":"first","mode":"create"}),
1241            ToolContext {
1242                workspace: root.clone(),
1243                cancellation: tokio_util::sync::CancellationToken::new(),
1244            },
1245        )
1246        .await
1247        .unwrap();
1248        let hash = format!("{:x}", Sha256::digest(b"first"));
1249        tool.execute(
1250            json!({"path":"file.txt","content":"second","mode":"replace","expected_sha256":hash}),
1251            ToolContext {
1252                workspace: root.clone(),
1253                cancellation: tokio_util::sync::CancellationToken::new(),
1254            },
1255        )
1256        .await
1257        .unwrap();
1258        assert_eq!(
1259            std::fs::read_to_string(root.join("file.txt")).unwrap(),
1260            "second"
1261        );
1262        let result = tool
1263            .execute(
1264                json!({"path":"file.txt","content":"third","mode":"replace","expected_sha256":"deadbeef"}),
1265                ToolContext {
1266                    workspace: root,
1267                    cancellation: tokio_util::sync::CancellationToken::new(),
1268                },
1269            )
1270            .await;
1271        assert!(result.unwrap_err().to_string().contains("changed"));
1272    }
1273
1274    #[tokio::test]
1275    async fn write_rejects_symlink_escape() {
1276        let workspace = tempfile::tempdir().unwrap();
1277        let outside = tempfile::tempdir().unwrap();
1278        symlink(outside.path(), workspace.path().join("escape")).unwrap();
1279        let tool = WriteTool { max_bytes: 100 };
1280        let result = tool
1281            .execute(
1282                json!({"path":"escape/file.txt","content":"nope","mode":"create"}),
1283                ToolContext {
1284                    workspace: workspace.path().canonicalize().unwrap(),
1285                    cancellation: tokio_util::sync::CancellationToken::new(),
1286                },
1287            )
1288            .await;
1289        assert!(result.unwrap_err().to_string().contains("workspace"));
1290        assert!(!outside.path().join("file.txt").exists());
1291    }
1292
1293    #[tokio::test]
1294    async fn bash_timeout_terminates_the_process() {
1295        let workspace = tempfile::tempdir().unwrap();
1296        let tool = BashTool {
1297            timeout: Duration::from_millis(50),
1298            max_timeout: Duration::from_millis(50),
1299            output_limit: 100,
1300        };
1301        let started = std::time::Instant::now();
1302        let output = tool
1303            .execute(
1304                json!({"command":"sleep 5"}),
1305                ToolContext {
1306                    workspace: workspace.path().canonicalize().unwrap(),
1307                    cancellation: tokio_util::sync::CancellationToken::new(),
1308                },
1309            )
1310            .await
1311            .unwrap();
1312        assert!(output.is_error);
1313        assert!(started.elapsed() < Duration::from_secs(3));
1314    }
1315
1316    #[tokio::test]
1317    async fn bash_output_is_bounded_and_reports_truncation() {
1318        let workspace = tempfile::tempdir().unwrap();
1319        let tool = BashTool {
1320            timeout: SHELL_STARTUP,
1321            max_timeout: SHELL_STARTUP,
1322            output_limit: 8,
1323        };
1324        let output = tool
1325            .execute(
1326                json!({"command":"printf 12345678901234567890"}),
1327                ToolContext {
1328                    workspace: workspace.path().canonicalize().unwrap(),
1329                    cancellation: tokio_util::sync::CancellationToken::new(),
1330                },
1331            )
1332            .await
1333            .unwrap();
1334        assert!(output.truncated);
1335        assert!(output.content.contains("12345678"));
1336        assert!(!output.content.contains("123456789"));
1337    }
1338
1339    #[tokio::test]
1340    async fn bash_cancellation_terminates_the_process_group() {
1341        let workspace = tempfile::tempdir().unwrap();
1342        let tool = BashTool {
1343            timeout: Duration::from_secs(30),
1344            max_timeout: Duration::from_secs(30),
1345            output_limit: 100,
1346        };
1347        let cancellation = tokio_util::sync::CancellationToken::new();
1348        let cancel = cancellation.clone();
1349        let started = std::time::Instant::now();
1350        let execution = tokio::spawn(async move {
1351            tool.execute(
1352                json!({"command":"sleep 30"}),
1353                ToolContext {
1354                    workspace: workspace.path().canonicalize().unwrap(),
1355                    cancellation,
1356                },
1357            )
1358            .await
1359        });
1360        tokio::time::sleep(Duration::from_millis(50)).await;
1361        cancel.cancel();
1362        let error = execution.await.unwrap().unwrap_err();
1363        assert!(error.to_string().contains("cancelled"));
1364        assert!(started.elapsed() < Duration::from_secs(3));
1365    }
1366
1367    #[tokio::test]
1368    async fn background_descendant_cannot_hold_output_pipes_open() {
1369        let workspace = tempfile::tempdir().unwrap();
1370        let root = workspace.path().canonicalize().unwrap();
1371        let tool = BashTool {
1372            timeout: SHELL_STARTUP,
1373            max_timeout: SHELL_STARTUP,
1374            output_limit: 100,
1375        };
1376        let output = tool
1377            .execute(
1378                json!({"command":"sleep 60 & echo $! > background.pid; exit 0"}),
1379                ToolContext {
1380                    workspace: root.clone(),
1381                    cancellation: tokio_util::sync::CancellationToken::new(),
1382                },
1383            )
1384            .await
1385            .unwrap();
1386        let returned = std::time::SystemTime::now();
1387        assert!(!output.is_error);
1388        // Time from the shell's last write, which excludes its startup.
1389        let exited = std::fs::metadata(root.join("background.pid"))
1390            .unwrap()
1391            .modified()
1392            .unwrap();
1393        assert!(returned.duration_since(exited).unwrap_or_default() < Duration::from_secs(3));
1394        let pid: i32 = std::fs::read_to_string(root.join("background.pid"))
1395            .unwrap()
1396            .trim()
1397            .parse()
1398            .unwrap();
1399        assert!(
1400            wait_for(Duration::from_secs(5), || is_gone(pid))
1401                .await
1402                .is_some(),
1403            "background descendant {pid} survived tool completion"
1404        );
1405    }
1406
1407    #[tokio::test]
1408    async fn cancellation_kills_a_term_ignoring_descendant() {
1409        let workspace = tempfile::tempdir().unwrap();
1410        let root = workspace.path().canonicalize().unwrap();
1411        let tool = BashTool {
1412            timeout: Duration::from_secs(30),
1413            max_timeout: Duration::from_secs(30),
1414            output_limit: 100,
1415        };
1416        let cancellation = tokio_util::sync::CancellationToken::new();
1417        let cancel = cancellation.clone();
1418        let command_root = root.clone();
1419        let execution = tokio::spawn(async move {
1420            tool.execute(
1421                json!({"command":"trap '' TERM; (trap '' TERM; sleep 30) & echo $! > stubborn.pid; wait"}),
1422                ToolContext {
1423                    workspace: command_root,
1424                    cancellation,
1425                },
1426            )
1427            .await
1428        });
1429        let pid_path = root.join("stubborn.pid");
1430        let pid = wait_for(SHELL_STARTUP, || {
1431            std::fs::read_to_string(&pid_path)
1432                .ok()
1433                .and_then(|value| value.trim().parse::<i32>().ok())
1434        })
1435        .await
1436        .expect("command did not report its descendant pid");
1437        let started = std::time::Instant::now();
1438        cancel.cancel();
1439        let error = execution.await.unwrap().unwrap_err();
1440        assert!(error.to_string().contains("cancelled"));
1441        assert!(started.elapsed() < Duration::from_secs(3));
1442        assert!(
1443            wait_for(Duration::from_secs(5), || is_gone(pid))
1444                .await
1445                .is_some(),
1446            "TERM-ignoring descendant {pid} survived cancellation"
1447        );
1448    }
1449
1450    /// Fake agents run through `bash` so no test ever executes a file that a
1451    /// concurrently forked test process may still hold open for writing
1452    /// (which fails spawning with ETXTBSY).
1453    fn fake_agent(
1454        workspace: &Path,
1455        name: &str,
1456        script: &str,
1457        args: &[&str],
1458        environment: Vec<(OsString, OsString)>,
1459    ) -> NativeAgentTool {
1460        let script_path = workspace.join("fake-agent.sh");
1461        std::fs::write(&script_path, script).unwrap();
1462        let mut fixed = vec![script_path.display().to_string()];
1463        fixed.extend(args.iter().map(|arg| arg.to_string()));
1464        NativeAgentTool::new(
1465            name.into(),
1466            AgentAdapterConfig {
1467                command: "bash".into(),
1468                args: fixed,
1469                model_args: vec!["--model".into(), "{model}".into()],
1470                effort_args: vec!["--effort".into(), "{effort}".into()],
1471                environment,
1472            },
1473            Timeouts {
1474                default: Duration::from_secs(2),
1475                max: Duration::from_secs(5),
1476            },
1477            1024,
1478        )
1479    }
1480
1481    fn context(workspace: &Path) -> ToolContext {
1482        ToolContext {
1483            workspace: workspace.canonicalize().unwrap(),
1484            cancellation: tokio_util::sync::CancellationToken::new(),
1485        }
1486    }
1487
1488    #[tokio::test]
1489    async fn native_agent_preserves_argument_boundaries() {
1490        let workspace = tempfile::tempdir().unwrap();
1491        let tool = fake_agent(
1492            workspace.path(),
1493            "agent_fake",
1494            "pwd\nprintf '%s\\n' \"$@\"\n",
1495            &["--fixed"],
1496            Vec::new(),
1497        );
1498        let output = tool
1499            .execute(
1500                json!({"prompt":"hello; echo unsafe"}),
1501                context(workspace.path()),
1502            )
1503            .await
1504            .unwrap();
1505        assert!(output.content.contains("--fixed"));
1506        assert!(output.content.contains("hello; echo unsafe"));
1507        assert!(
1508            output
1509                .content
1510                .contains(&workspace.path().display().to_string())
1511        );
1512    }
1513
1514    #[tokio::test]
1515    async fn native_agent_maps_model_and_effort_to_adapter_flags() {
1516        let workspace = tempfile::tempdir().unwrap();
1517        let tool = fake_agent(
1518            workspace.path(),
1519            "agent_claude",
1520            "printf '%s\\n' \"$@\"\n",
1521            &["-p"],
1522            Vec::new(),
1523        );
1524        let properties = &tool.spec().parameters["properties"];
1525        assert_eq!(properties["effort"]["enum"], json!(AGENT_EFFORTS));
1526        assert_eq!(properties["model"]["type"], "string");
1527        let arguments = json!({"prompt":"hi","model":"sonnet","effort":"medium"});
1528        assert!(
1529            tool.approval_summary(&arguments)
1530                .unwrap()
1531                .contains(r#""--model", "sonnet", "--effort", "medium""#)
1532        );
1533        let output = tool
1534            .execute(arguments, context(workspace.path()))
1535            .await
1536            .unwrap();
1537        let output: Value = serde_json::from_str(&output.content).unwrap();
1538        assert_eq!(
1539            output["output"],
1540            "-p\n--model\nsonnet\n--effort\nmedium\nhi\n"
1541        );
1542        for invalid in [
1543            json!({"prompt":"hi","model":"--dangerously-skip-permissions"}),
1544            json!({"prompt":"hi","model":"sonnet medium"}),
1545            json!({"prompt":"hi","effort":"extreme"}),
1546            json!({"prompt":"hi","model":"@/etc/passwd"}),
1547            json!({"prompt":"--resume"}),
1548        ] {
1549            assert!(tool.risk(&invalid).is_err());
1550        }
1551        let fixed_only = NativeAgentTool::new(
1552            "agent_pi".into(),
1553            AgentAdapterConfig {
1554                command: "pi".into(),
1555                args: vec!["-p".into()],
1556                model_args: Vec::new(),
1557                effort_args: Vec::new(),
1558                environment: Vec::new(),
1559            },
1560            Timeouts {
1561                default: Duration::from_secs(2),
1562                max: Duration::from_secs(2),
1563            },
1564            1024,
1565        );
1566        assert!(
1567            fixed_only.spec().parameters["properties"]
1568                .get("model")
1569                .is_none()
1570        );
1571        let error = fixed_only
1572            .risk(&json!({"prompt":"hi","model":"sonnet"}))
1573            .unwrap_err();
1574        assert!(
1575            error
1576                .to_string()
1577                .contains("does not support selecting a model")
1578        );
1579    }
1580
1581    #[test]
1582    fn native_agent_model_hints_name_the_adapter_family_and_default() {
1583        let workspace = tempfile::tempdir().unwrap();
1584        let description = |name: &str, field: &str| {
1585            fake_agent(workspace.path(), name, "", &[], Vec::new())
1586                .spec()
1587                .parameters["properties"][field]["description"]
1588                .as_str()
1589                .unwrap()
1590                .to_owned()
1591        };
1592        let claude = description("agent_claude", "model");
1593        let codex = description("agent_codex", "model");
1594        let other = description("agent_other", "model");
1595        assert!(claude.contains("sonnet or opus"));
1596        for text in [&codex, &other] {
1597            assert!(!text.contains("sonnet"), "{text}");
1598        }
1599        assert!(codex.contains("not a Claude alias"));
1600        for text in [claude, codex, other, description("agent_codex", "effort")] {
1601            assert!(
1602                text.contains("omit to use the agent's configured default"),
1603                "{text}"
1604            );
1605        }
1606    }
1607
1608    #[tokio::test]
1609    async fn signed_out_agent_failure_names_the_host_login_command() {
1610        let workspace = tempfile::tempdir().unwrap();
1611        let tool = fake_agent(
1612            workspace.path(),
1613            "agent_claude",
1614            "echo 'Not logged in ยท Please run /login'\nexit 1\n",
1615            &[],
1616            Vec::new(),
1617        );
1618        let output = tool
1619            .execute(json!({"prompt":"hi"}), context(workspace.path()))
1620            .await
1621            .unwrap();
1622        assert!(output.is_error);
1623        let content: Value = serde_json::from_str(&output.content).unwrap();
1624        assert!(
1625            content["hint"]
1626                .as_str()
1627                .unwrap()
1628                .ends_with("scv agents login claude")
1629        );
1630        let other = fake_agent(
1631            workspace.path(),
1632            "agent_claude",
1633            "echo 'disk full'\nexit 1\n",
1634            &[],
1635            Vec::new(),
1636        );
1637        let output = other
1638            .execute(json!({"prompt":"hi"}), context(workspace.path()))
1639            .await
1640            .unwrap();
1641        assert!(output.is_error);
1642        assert!(!output.content.contains("hint"));
1643    }
1644
1645    #[tokio::test]
1646    async fn native_agent_uses_instance_private_environment() {
1647        let workspace = tempfile::tempdir().unwrap();
1648        let home = workspace.path().join("private-home");
1649        let tool = fake_agent(
1650            workspace.path(),
1651            "agent_codex",
1652            "printf '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",
1653            &[],
1654            vec![
1655                ("HOME".into(), home.clone().into()),
1656                ("SCV_HOME".into(), home.clone().into()),
1657                ("CODEX_HOME".into(), home.join("codex").into()),
1658            ],
1659        );
1660        let output = tool
1661            .execute(
1662                json!({"prompt":"print environment"}),
1663                context(workspace.path()),
1664            )
1665            .await
1666            .unwrap();
1667        assert!(output.content.contains(&format!("HOME={}", home.display())));
1668        assert!(
1669            output
1670                .content
1671                .contains(&format!("CODEX_HOME={}/codex", home.display()))
1672        );
1673        assert!(output.content.contains("SCV_CONFIG=unset"));
1674        assert!(output.content.contains("OPENAI_API_KEY=unset"));
1675        assert!(output.content.contains("CODEX_API_KEY=unset"));
1676    }
1677
1678    #[tokio::test]
1679    async fn native_agent_runs_in_a_contained_directory() {
1680        let workspace = tempfile::tempdir().unwrap();
1681        let outside = tempfile::tempdir().unwrap();
1682        let root = workspace.path().canonicalize().unwrap();
1683        std::fs::create_dir(root.join("project")).unwrap();
1684        std::fs::write(root.join("notes.txt"), "not a directory").unwrap();
1685        symlink(outside.path(), root.join("escape")).unwrap();
1686        symlink(root.join("project"), root.join("inner-link")).unwrap();
1687        let tool = fake_agent(&root, "agent_codex", "pwd\n", &[], Vec::new());
1688        let run = |arguments: Value| tool.execute(arguments, context(&root));
1689
1690        for arguments in [
1691            json!({"prompt":"hi"}),
1692            json!({"prompt":"hi","cwd":""}),
1693            json!({"prompt":"hi","cwd":"  ","model":"","effort":" "}),
1694        ] {
1695            let output = run(arguments.clone()).await.unwrap();
1696            let output: Value = serde_json::from_str(&output.content).unwrap();
1697            assert_eq!(
1698                output["output"],
1699                format!("{}\n", root.display()),
1700                "{arguments}"
1701            );
1702        }
1703        for cwd in [
1704            "project".to_owned(),
1705            "project/".to_owned(),
1706            "inner-link".to_owned(),
1707            root.join("project").display().to_string(),
1708        ] {
1709            let output = run(json!({"prompt":"hi","cwd":cwd})).await.unwrap();
1710            let output: Value = serde_json::from_str(&output.content).unwrap();
1711            assert_eq!(
1712                output["output"],
1713                format!("{}\n", root.join("project").display()),
1714                "{cwd}"
1715            );
1716        }
1717        for (cwd, error) in [
1718            ("..", "outside the workspace"),
1719            ("escape", "outside the workspace"),
1720            ("/", "outside the workspace"),
1721            ("notes.txt", "not a directory"),
1722            ("missing", "No such file"),
1723        ] {
1724            let result = run(json!({"prompt":"hi","cwd":cwd})).await;
1725            assert!(
1726                result.as_ref().unwrap_err().to_string().contains(error),
1727                "{cwd}: {result:?}"
1728            );
1729        }
1730        assert!(tool.risk(&json!({"prompt":"hi","cwd":"a\0b"})).is_err());
1731        assert!(
1732            tool.risk(&json!({"prompt":"hi","cwd":"x".repeat(MAX_AGENT_CWD_BYTES + 1)}))
1733                .is_err()
1734        );
1735        let summary = tool
1736            .approval_summary(&json!({"prompt":"hi","cwd":"project","timeout_seconds":4}))
1737            .unwrap();
1738        assert!(summary.contains(r#"in "project" (inside the workspace) for up to 4 seconds"#));
1739        assert!(
1740            tool.approval_summary(&json!({"prompt":"hi"}))
1741                .unwrap()
1742                .contains("in the workspace root for up to 2 seconds")
1743        );
1744        let description = tool.spec().parameters["properties"]["cwd"]["description"]
1745            .as_str()
1746            .unwrap()
1747            .to_owned();
1748        assert!(description.contains("AGENTS.md"));
1749    }
1750
1751    #[tokio::test]
1752    async fn per_call_timeouts_may_rise_to_the_ceiling_but_not_past_it() {
1753        let timeouts = Timeouts {
1754            default: Duration::from_secs(120),
1755            max: Duration::from_secs(1800),
1756        };
1757        assert_eq!(timeouts.resolve(None).unwrap(), Duration::from_secs(120));
1758        assert_eq!(timeouts.resolve(Some(30)).unwrap(), Duration::from_secs(30));
1759        assert_eq!(
1760            timeouts.resolve(Some(1800)).unwrap(),
1761            Duration::from_secs(1800)
1762        );
1763        assert!(timeouts.resolve(Some(0)).is_err());
1764        assert!(
1765            timeouts
1766                .resolve(Some(1801))
1767                .unwrap_err()
1768                .to_string()
1769                .contains("maximum of 1800 seconds (tools.max_timeout_seconds)")
1770        );
1771
1772        let workspace = tempfile::tempdir().unwrap();
1773        let agent = fake_agent(
1774            workspace.path(),
1775            "agent_codex",
1776            "echo ran\n",
1777            &[],
1778            Vec::new(),
1779        );
1780        let schema = &agent.spec().parameters["properties"]["timeout_seconds"];
1781        assert_eq!(schema["maximum"], 5);
1782        assert!(
1783            schema["description"]
1784                .as_str()
1785                .unwrap()
1786                .contains("Defaults to 2; at most 5")
1787        );
1788        assert!(
1789            agent
1790                .risk(&json!({"prompt":"hi","timeout_seconds":5}))
1791                .is_ok()
1792        );
1793        assert!(
1794            agent
1795                .risk(&json!({"prompt":"hi","timeout_seconds":6}))
1796                .is_err()
1797        );
1798        assert!(
1799            agent
1800                .execute(
1801                    json!({"prompt":"hi","timeout_seconds":6}),
1802                    context(workspace.path())
1803                )
1804                .await
1805                .is_err()
1806        );
1807
1808        let bash = BashTool {
1809            timeout: Duration::from_secs(1),
1810            max_timeout: Duration::from_secs(3),
1811            output_limit: 100,
1812        };
1813        assert_eq!(
1814            bash.spec().parameters["properties"]["timeout_seconds"]["maximum"],
1815            3
1816        );
1817        assert!(
1818            bash.risk(&json!({"command":"true","timeout_seconds":3}))
1819                .is_ok()
1820        );
1821        assert!(
1822            bash.risk(&json!({"command":"true","timeout_seconds":4}))
1823                .unwrap_err()
1824                .to_string()
1825                .contains("tools.max_timeout_seconds")
1826        );
1827    }
1828}