Skip to main content

scv_tools/
lib.rs

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