Skip to main content

scv_tools/
lib.rs

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