Skip to main content

scv_tools/
lib.rs

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