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