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