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