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,
36 pub output_limit_bytes: usize,
37 pub max_read_bytes: usize,
38 pub max_write_bytes: usize,
39}
40
41impl Default for ToolsConfig {
42 fn default() -> Self {
43 Self {
44 command_timeout: Duration::from_secs(120),
45 output_limit_bytes: 64 * 1024,
46 max_read_bytes: 256 * 1024,
47 max_write_bytes: 1024 * 1024,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct AgentAdapterConfig {
54 pub command: String,
55 pub args: Vec<String>,
56 pub environment: Vec<(OsString, OsString)>,
58}
59
60pub type SkillMap = HashMap<String, PathBuf>;
61
62pub fn builtin_registry(
63 config: ToolsConfig,
64 skills: SkillMap,
65 skill_roots: Vec<PathBuf>,
66 max_skill_bytes: usize,
67 adapters: HashMap<String, AgentAdapterConfig>,
68) -> Result<ToolRegistry, ToolError> {
69 let mut registry = ToolRegistry::default();
70 registry.register(Arc::new(ReadTool {
71 max_bytes: config.max_read_bytes,
72 }))?;
73 registry.register(Arc::new(ReadSkillTool {
74 skills,
75 roots: skill_roots,
76 max_bytes: max_skill_bytes,
77 }))?;
78 registry.register(Arc::new(WriteTool {
79 max_bytes: config.max_write_bytes,
80 }))?;
81 registry.register(Arc::new(BashTool {
82 timeout: config.command_timeout,
83 output_limit: config.output_limit_bytes,
84 }))?;
85 for (name, adapter) in adapters {
86 registry.register(Arc::new(NativeAgentTool::new(
87 name,
88 adapter,
89 config.command_timeout,
90 config.output_limit_bytes,
91 )))?;
92 }
93 Ok(registry)
94}
95
96struct ReadTool {
97 max_bytes: usize,
98}
99
100#[derive(Deserialize)]
101#[serde(deny_unknown_fields)]
102struct ReadArgs {
103 path: String,
104 #[serde(default)]
105 offset: usize,
106 limit: Option<usize>,
107}
108
109#[async_trait]
110impl Tool for ReadTool {
111 fn spec(&self) -> ToolSpec {
112 ToolSpec {
113 name: "read".into(),
114 description: "Read a bounded UTF-8 file inside the workspace".into(),
115 parameters: json!({
116 "type":"object",
117 "properties":{
118 "path":{"type":"string"},
119 "offset":{"type":"integer","minimum":0},
120 "limit":{"type":"integer","minimum":1}
121 },
122 "required":["path"],
123 "additionalProperties":false
124 }),
125 }
126 }
127
128 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
129 let args: ReadArgs = parse_args(arguments)?;
130 validate_read_args(&args)?;
131 Ok(if is_secret_like(Path::new(&args.path)) {
132 ToolRisk::Filesystem
133 } else {
134 ToolRisk::ReadOnly
135 })
136 }
137
138 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
139 let args: ReadArgs = parse_args(arguments)?;
140 validate_read_args(&args)?;
141 Ok(format!("Read {}", args.path))
142 }
143
144 async fn execute(
145 &self,
146 arguments: Value,
147 context: ToolContext,
148 ) -> Result<ToolOutput, ToolError> {
149 let args: ReadArgs = parse_args(&arguments)?;
150 validate_read_args(&args)?;
151 let requested = args.limit.unwrap_or(self.max_bytes).min(self.max_bytes);
152 let offset = u64::try_from(args.offset).unwrap_or(u64::MAX);
153 let workspace = context.workspace.clone();
154 let display_path = args.path.clone();
155 let relative = PathBuf::from(&args.path);
156 validate_relative(&relative)?;
157 let read = tokio::task::spawn_blocking(move || {
158 let root = open_workspace(&workspace)?;
159 let mut file = root
160 .open(&relative)
161 .map_err(|error| map_cap_error("read", &display_path, error))?;
162 let total_bytes = file
163 .metadata()
164 .map_err(|error| ToolError(format!("stat {display_path}: {error}")))?
165 .len();
166 let start = offset.min(total_bytes);
167 std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(start))
168 .map_err(|error| ToolError(format!("seek {display_path}: {error}")))?;
169 let mut bytes = Vec::with_capacity(requested.min(8192));
170 std::io::Read::take(&mut file, u64::try_from(requested).unwrap_or(u64::MAX))
171 .read_to_end(&mut bytes)
172 .map_err(|error| ToolError(format!("read {display_path}: {error}")))?;
173 Ok::<_, ToolError>((bytes, total_bytes, start))
174 });
175 let (bytes, total_bytes, start) = tokio::select! {
176 result = read => result.map_err(|error| ToolError(format!("read task failed: {error}")))??,
177 _ = context.cancellation.cancelled() => return Err(ToolError("read cancelled".into())),
178 };
179 let content = std::str::from_utf8(&bytes)
180 .map_err(|_| ToolError(format!("selected range of {} is not UTF-8", args.path)))?;
181 let end = start.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
182 let truncated = start > 0 || end < total_bytes;
183 Ok(ToolOutput {
184 content: json!({
185 "path": args.path,
186 "content": content,
187 "total_bytes": total_bytes,
188 "offset": start,
189 "truncated": truncated
190 })
191 .to_string(),
192 is_error: false,
193 truncated,
194 })
195 }
196}
197
198struct ReadSkillTool {
199 skills: SkillMap,
200 roots: Vec<PathBuf>,
201 max_bytes: usize,
202}
203
204#[derive(Deserialize)]
205#[serde(deny_unknown_fields)]
206struct ReadSkillArgs {
207 name: String,
208}
209
210#[async_trait]
211impl Tool for ReadSkillTool {
212 fn spec(&self) -> ToolSpec {
213 ToolSpec {
214 name: "read_skill".into(),
215 description: "Load a discovered SCV skill by name".into(),
216 parameters: json!({
217 "type":"object",
218 "properties":{"name":{"type":"string"}},
219 "required":["name"],
220 "additionalProperties":false
221 }),
222 }
223 }
224
225 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
226 let _: ReadSkillArgs = parse_args(arguments)?;
227 Ok(ToolRisk::ReadOnly)
228 }
229
230 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
231 let args: ReadSkillArgs = parse_args(arguments)?;
232 Ok(format!("Load skill {}", args.name))
233 }
234
235 async fn execute(
236 &self,
237 arguments: Value,
238 context: ToolContext,
239 ) -> Result<ToolOutput, ToolError> {
240 let args: ReadSkillArgs = parse_args(&arguments)?;
241 let configured = self
242 .skills
243 .get(&args.name)
244 .ok_or_else(|| ToolError(format!("unknown skill: {}", args.name)))?;
245 let path = std::fs::canonicalize(configured)
246 .map_err(|error| ToolError(format!("load skill {}: {error}", args.name)))?;
247 if !self.roots.iter().any(|root| path.starts_with(root)) {
248 return Err(ToolError("skill path escaped its configured root".into()));
249 }
250 let max_bytes = self.max_bytes;
251 let skill_name = args.name.clone();
252 let bytes = tokio::select! {
253 result = tokio::task::spawn_blocking(move || {
254 let mut file = std::fs::File::open(&path)
255 .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
256 let mut bytes = Vec::with_capacity(max_bytes.min(8192));
257 std::io::Read::take(
258 &mut file,
259 u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1),
260 )
261 .read_to_end(&mut bytes)
262 .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
263 Ok::<_, ToolError>(bytes)
264 }) => result.map_err(|error| ToolError(format!("skill read task failed: {error}")))??,
265 _ = context.cancellation.cancelled() => return Err(ToolError("skill read cancelled".into())),
266 };
267 let end = bytes.len().min(self.max_bytes);
268 let content = std::str::from_utf8(&bytes[..end])
269 .map_err(|_| ToolError("skill is not UTF-8".into()))?;
270 Ok(ToolOutput {
271 content: content.to_owned(),
272 is_error: false,
273 truncated: end < bytes.len(),
274 })
275 }
276}
277
278struct WriteTool {
279 max_bytes: usize,
280}
281
282#[derive(Deserialize)]
283#[serde(deny_unknown_fields)]
284struct WriteArgs {
285 path: String,
286 content: String,
287 mode: WriteMode,
288 expected_sha256: Option<String>,
289}
290
291#[derive(Deserialize)]
292#[serde(rename_all = "snake_case")]
293enum WriteMode {
294 Create,
295 Replace,
296}
297
298#[async_trait]
299impl Tool for WriteTool {
300 fn spec(&self) -> ToolSpec {
301 ToolSpec {
302 name: "write".into(),
303 description: "Atomically create or replace a UTF-8 file inside the workspace".into(),
304 parameters: json!({
305 "type":"object",
306 "properties":{
307 "path":{"type":"string"},
308 "content":{"type":"string"},
309 "mode":{"type":"string","enum":["create","replace"]},
310 "expected_sha256":{"type":"string"}
311 },
312 "required":["path","content","mode"],
313 "additionalProperties":false
314 }),
315 }
316 }
317
318 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
319 let _: WriteArgs = parse_args(arguments)?;
320 Ok(ToolRisk::Filesystem)
321 }
322
323 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
324 let args: WriteArgs = parse_args(arguments)?;
325 let mode = match args.mode {
326 WriteMode::Create => "Create",
327 WriteMode::Replace => "Replace",
328 };
329 Ok(format!(
330 "{mode} {} ({} bytes)",
331 args.path,
332 args.content.len()
333 ))
334 }
335
336 async fn execute(
337 &self,
338 arguments: Value,
339 context: ToolContext,
340 ) -> Result<ToolOutput, ToolError> {
341 let args: WriteArgs = parse_args(&arguments)?;
342 if args.content.len() > self.max_bytes {
343 return Err(ToolError(format!(
344 "write exceeds {} byte limit",
345 self.max_bytes
346 )));
347 }
348 let workspace = context.workspace.clone();
349 let cancellation = context.cancellation.clone();
350 tokio::task::spawn_blocking(move || {
351 if cancellation.is_cancelled() {
352 return Err(ToolError("write cancelled".into()));
353 }
354 let path = PathBuf::from(&args.path);
355 validate_relative(&path)?;
356 let root = open_workspace(&workspace)?;
357 let exists = match root.symlink_metadata(&path) {
358 Ok(_) => true,
359 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
360 Err(error) => return Err(map_cap_error("inspect", &args.path, error)),
361 };
362 match args.mode {
363 WriteMode::Create if exists => {
364 return Err(ToolError(format!("{} already exists", args.path)));
365 }
366 WriteMode::Replace if !exists => {
367 return Err(ToolError(format!("{} does not exist", args.path)));
368 }
369 _ => {}
370 }
371 if let Some(expected) = args.expected_sha256 {
372 let mut current_file = root
373 .open(&path)
374 .map_err(|error| map_cap_error("hash", &args.path, error))?;
375 let mut current = Vec::new();
376 current_file
377 .read_to_end(&mut current)
378 .map_err(|error| ToolError(format!("hash {}: {error}", args.path)))?;
379 let actual = format!("{:x}", Sha256::digest(current));
380 if actual != expected.to_ascii_lowercase() {
381 return Err(ToolError(format!(
382 "{} changed: expected sha256 {}, found {}",
383 args.path, expected, actual
384 )));
385 }
386 }
387 let parent = path.parent().unwrap_or_else(|| Path::new("."));
388 root.create_dir_all(parent)
389 .map_err(|error| map_cap_error("create directory for", &args.path, error))?;
390 let temporary_path = unique_temporary_path(parent);
391 let mut options = OpenOptions::new();
392 options.write(true).create_new(true);
393 let mut temporary = root
394 .open_with(&temporary_path, &options)
395 .map_err(|error| map_cap_error("create temporary file for", &args.path, error))?;
396 let write_result = (|| {
397 temporary
398 .write_all(args.content.as_bytes())
399 .and_then(|_| temporary.sync_all())
400 .map_err(|error| ToolError(format!("write {}: {error}", args.path)))?;
401 if cancellation.is_cancelled() {
402 return Err(ToolError("write cancelled".into()));
403 }
404 match args.mode {
405 WriteMode::Create => root
406 .hard_link(&temporary_path, &root, &path)
407 .map_err(|error| map_cap_error("create", &args.path, error)),
408 WriteMode::Replace => root
409 .rename(&temporary_path, &root, &path)
410 .map_err(|error| map_cap_error("replace", &args.path, error)),
411 }
412 })();
413 if matches!(args.mode, WriteMode::Create) || write_result.is_err() {
414 let _ = root.remove_file(&temporary_path);
415 }
416 write_result?;
417 Ok(ToolOutput::success(
418 json!({
419 "path":args.path,
420 "bytes":args.content.len(),
421 "sha256":format!("{:x}", Sha256::digest(args.content.as_bytes()))
422 })
423 .to_string(),
424 ))
425 })
426 .await
427 .map_err(|error| ToolError(format!("write task failed: {error}")))?
428 }
429}
430
431struct BashTool {
432 timeout: Duration,
433 output_limit: usize,
434}
435
436#[derive(Deserialize)]
437#[serde(deny_unknown_fields)]
438struct BashArgs {
439 command: String,
440 timeout_seconds: Option<u64>,
441}
442
443#[async_trait]
444impl Tool for BashTool {
445 fn spec(&self) -> ToolSpec {
446 ToolSpec {
447 name: "bash".into(),
448 description: "Run a Bash command in the workspace (not sandboxed)".into(),
449 parameters: json!({
450 "type":"object",
451 "properties":{
452 "command":{"type":"string"},
453 "timeout_seconds":{"type":"integer","minimum":1}
454 },
455 "required":["command"],
456 "additionalProperties":false
457 }),
458 }
459 }
460
461 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
462 let args: BashArgs = parse_args(arguments)?;
463 validate_process_args(&args.command, args.timeout_seconds)?;
464 Ok(ToolRisk::Process)
465 }
466
467 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
468 let args: BashArgs = parse_args(arguments)?;
469 validate_process_args(&args.command, args.timeout_seconds)?;
470 Ok(format!(
471 "Run with /bin/bash -lc: {}",
472 bounded(&args.command, 2000)
473 ))
474 }
475
476 async fn execute(
477 &self,
478 arguments: Value,
479 context: ToolContext,
480 ) -> Result<ToolOutput, ToolError> {
481 let args: BashArgs = parse_args(&arguments)?;
482 validate_process_args(&args.command, args.timeout_seconds)?;
483 let requested = args
484 .timeout_seconds
485 .map(Duration::from_secs)
486 .unwrap_or(self.timeout)
487 .min(self.timeout);
488 execute_process(
489 ProcessSpec {
490 executable: OsString::from("/bin/bash"),
491 args: vec![OsString::from("-lc"), OsString::from(args.command)],
492 cwd: context.workspace,
493 environment: Vec::new(),
494 sanitize_scv_environment: false,
495 timeout: requested,
496 output_limit: self.output_limit,
497 },
498 context.cancellation,
499 )
500 .await
501 }
502}
503
504struct NativeAgentTool {
505 name: String,
506 command: String,
507 resolved: Option<PathBuf>,
508 args: Vec<String>,
509 environment: Vec<(OsString, OsString)>,
510 timeout: Duration,
511 output_limit: usize,
512}
513
514impl NativeAgentTool {
515 fn new(
516 name: String,
517 config: AgentAdapterConfig,
518 timeout: Duration,
519 output_limit: usize,
520 ) -> Self {
521 let resolved = which::which(&config.command).ok();
522 Self {
523 name,
524 command: config.command,
525 resolved,
526 args: config.args,
527 environment: config.environment,
528 timeout,
529 output_limit,
530 }
531 }
532}
533
534#[derive(Deserialize)]
535#[serde(deny_unknown_fields)]
536struct AgentArgs {
537 prompt: String,
538 timeout_seconds: Option<u64>,
539}
540
541#[async_trait]
542impl Tool for NativeAgentTool {
543 fn spec(&self) -> ToolSpec {
544 ToolSpec {
545 name: self.name.clone(),
546 description: format!(
547 "Launch the configured {} CLI as a nested agent (not sandboxed)",
548 self.name
549 ),
550 parameters: json!({
551 "type":"object",
552 "properties":{
553 "prompt":{"type":"string"},
554 "timeout_seconds":{"type":"integer","minimum":1}
555 },
556 "required":["prompt"],
557 "additionalProperties":false
558 }),
559 }
560 }
561
562 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
563 let args: AgentArgs = parse_args(arguments)?;
564 validate_process_args(&args.prompt, args.timeout_seconds)?;
565 Ok(ToolRisk::Delegate)
566 }
567
568 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
569 let args: AgentArgs = parse_args(arguments)?;
570 validate_process_args(&args.prompt, args.timeout_seconds)?;
571 let executable = self.resolved.as_ref().map_or_else(
572 || self.command.as_str().into(),
573 |path| path.display().to_string(),
574 );
575 Ok(format!(
576 "Launch {executable} with fixed args {:?} and prompt {:?}. The nested agent has your user permissions.",
577 self.args,
578 bounded(&args.prompt, 2000)
579 ))
580 }
581
582 async fn execute(
583 &self,
584 arguments: Value,
585 context: ToolContext,
586 ) -> Result<ToolOutput, ToolError> {
587 let args: AgentArgs = parse_args(&arguments)?;
588 validate_process_args(&args.prompt, args.timeout_seconds)?;
589 let executable = self.resolved.as_ref().ok_or_else(|| {
590 ToolError(format!(
591 "{} executable {:?} was not found in PATH",
592 self.name, self.command
593 ))
594 })?;
595 let mut command_args: Vec<OsString> = self.args.iter().map(OsString::from).collect();
596 command_args.push(OsString::from(args.prompt));
597 let requested = args
598 .timeout_seconds
599 .map(Duration::from_secs)
600 .unwrap_or(self.timeout)
601 .min(self.timeout);
602 execute_process(
603 ProcessSpec {
604 executable: executable.as_os_str().to_owned(),
605 args: command_args,
606 cwd: context.workspace,
607 environment: self.environment.clone(),
608 sanitize_scv_environment: true,
609 timeout: requested,
610 output_limit: self.output_limit,
611 },
612 context.cancellation,
613 )
614 .await
615 }
616}
617
618struct ProcessSpec {
619 executable: OsString,
620 args: Vec<OsString>,
621 cwd: PathBuf,
622 environment: Vec<(OsString, OsString)>,
623 sanitize_scv_environment: bool,
624 timeout: Duration,
625 output_limit: usize,
626}
627
628async fn execute_process(
629 spec: ProcessSpec,
630 cancellation: tokio_util::sync::CancellationToken,
631) -> Result<ToolOutput, ToolError> {
632 let deadline = Instant::now() + spec.timeout;
633 let mut command = Command::new(&spec.executable);
634 command
635 .args(&spec.args)
636 .current_dir(&spec.cwd)
637 .envs(spec.environment)
638 .stdin(std::process::Stdio::null())
639 .stdout(std::process::Stdio::piped())
640 .stderr(std::process::Stdio::piped())
641 .kill_on_drop(true);
642 if spec.sanitize_scv_environment {
643 for variable in [
644 "SCV_CONFIG",
645 "SCV_MODEL",
646 "SCV_PROVIDER",
647 "SCV_BASE_URL",
648 "SCV_API_KEY_ENV",
649 "OPENAI_API_KEY",
650 "OPENAI_BASE_URL",
651 "OPENAI_ORG_ID",
652 "OPENAI_PROJECT_ID",
653 "CODEX_API_KEY",
654 "CODEX_BASE_URL",
655 "ANTHROPIC_API_KEY",
656 "ANTHROPIC_BASE_URL",
657 "ANTHROPIC_AUTH_TOKEN",
658 "GEMINI_API_KEY",
659 "GOOGLE_API_KEY",
660 "AZURE_OPENAI_API_KEY",
661 "AZURE_OPENAI_ENDPOINT",
662 ] {
663 command.env_remove(variable);
664 }
665 }
666 command.as_std_mut().process_group(0);
667 let mut child = command
668 .spawn()
669 .map_err(|error| ToolError(format!("launch {:?}: {error}", spec.executable)))?;
670 let pid = child
671 .id()
672 .ok_or_else(|| ToolError("child process has no pid".into()))? as i32;
673 let output = Arc::new(Mutex::new(BoundedOutput::new(spec.output_limit)));
674 let stdout_task = child.stdout.take().map(|stdout| {
675 let output = Arc::clone(&output);
676 tokio::spawn(drain_output(stdout, output))
677 });
678 let stderr_task = child.stderr.take().map(|stderr| {
679 let output = Arc::clone(&output);
680 tokio::spawn(drain_output(stderr, output))
681 });
682
683 enum Completion {
684 Exited(std::process::ExitStatus),
685 TimedOut,
686 Cancelled,
687 }
688 let completion = tokio::select! {
689 status = child.wait() => Completion::Exited(status.map_err(|error| ToolError(format!("wait for child: {error}")))?),
690 _ = cancellation.cancelled() => {
691 Completion::Cancelled
692 },
693 _ = sleep_until(deadline) => Completion::TimedOut,
694 };
695
696 let (status, timed_out, drain_deadline) = match completion {
697 Completion::Exited(status) => {
698 let cleanup_deadline = deadline.min(Instant::now() + Duration::from_secs(2));
699 let status =
700 terminate_group(pid, &mut child, Some(status), cleanup_deadline, true).await?;
701 (
702 status,
703 false,
704 deadline.min(Instant::now() + Duration::from_millis(250)),
705 )
706 }
707 Completion::TimedOut => {
708 let status = terminate_group(pid, &mut child, None, Instant::now(), false).await?;
709 (status, true, Instant::now() + Duration::from_millis(250))
710 }
711 Completion::Cancelled => {
712 let cleanup_deadline = Instant::now() + Duration::from_secs(2);
713 let _ = terminate_group(pid, &mut child, None, cleanup_deadline, true).await;
714 finish_drain(stdout_task, Instant::now() + Duration::from_millis(250)).await;
715 finish_drain(stderr_task, Instant::now() + Duration::from_millis(250)).await;
716 return Err(ToolError("process cancelled".into()));
717 }
718 };
719 finish_drain(stdout_task, drain_deadline).await;
720 finish_drain(stderr_task, drain_deadline).await;
721 let collected = output.lock().await;
722 let text = String::from_utf8_lossy(&collected.bytes).into_owned();
723 let content = json!({
724 "exit_code": status.code(),
725 "timed_out": timed_out,
726 "output": text,
727 "truncated": collected.truncated
728 })
729 .to_string();
730 Ok(ToolOutput {
731 content,
732 is_error: timed_out || !status.success(),
733 truncated: collected.truncated,
734 })
735}
736
737async fn terminate_group(
738 pid: i32,
739 child: &mut tokio::process::Child,
740 mut status: Option<std::process::ExitStatus>,
741 deadline: Instant,
742 graceful: bool,
743) -> Result<std::process::ExitStatus, ToolError> {
744 signal_group(
745 pid,
746 if graceful {
747 libc::SIGTERM
748 } else {
749 libc::SIGKILL
750 },
751 );
752 while Instant::now() < deadline {
753 if status.is_none() {
754 status = child
755 .try_wait()
756 .map_err(|error| ToolError(format!("wait for child: {error}")))?;
757 }
758 if !process_group_exists(pid)
759 && let Some(status) = status
760 {
761 return Ok(status);
762 }
763 sleep(Duration::from_millis(20)).await;
764 }
765 signal_group(pid, libc::SIGKILL);
767 if let Some(status) = status {
768 return Ok(status);
769 }
770 timeout(Duration::from_secs(1), child.wait())
771 .await
772 .map_err(|_| ToolError("child did not exit after process-group kill".into()))?
773 .map_err(|error| ToolError(format!("wait after KILL: {error}")))
774}
775
776fn signal_group(pid: i32, signal: i32) {
777 unsafe {
779 libc::kill(-pid, signal);
780 }
781}
782
783fn process_group_exists(pid: i32) -> bool {
784 let result = unsafe { libc::kill(-pid, 0) };
785 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
786}
787
788async fn finish_drain(task: Option<JoinHandle<()>>, deadline: Instant) {
789 let Some(mut task) = task else { return };
790 if timeout_at(deadline, &mut task).await.is_err() {
791 task.abort();
792 let _ = task.await;
793 }
794}
795
796async fn drain_output<R>(mut reader: R, output: Arc<Mutex<BoundedOutput>>)
797where
798 R: tokio::io::AsyncRead + Unpin,
799{
800 let mut chunk = [0u8; 8192];
801 loop {
802 match reader.read(&mut chunk).await {
803 Ok(0) | Err(_) => break,
804 Ok(read) => output.lock().await.push(&chunk[..read]),
805 }
806 }
807}
808
809struct BoundedOutput {
810 bytes: Vec<u8>,
811 limit: usize,
812 truncated: bool,
813}
814
815impl BoundedOutput {
816 fn new(limit: usize) -> Self {
817 Self {
818 bytes: Vec::with_capacity(limit.min(8192)),
819 limit,
820 truncated: false,
821 }
822 }
823
824 fn push(&mut self, bytes: &[u8]) {
825 let remaining = self.limit.saturating_sub(self.bytes.len());
826 self.bytes
827 .extend_from_slice(&bytes[..bytes.len().min(remaining)]);
828 self.truncated |= bytes.len() > remaining;
829 }
830}
831
832fn parse_args<T: for<'de> Deserialize<'de>>(value: &Value) -> Result<T, ToolError> {
833 serde_json::from_value(value.clone())
834 .map_err(|error| ToolError(format!("invalid arguments: {error}")))
835}
836
837fn validate_read_args(args: &ReadArgs) -> Result<(), ToolError> {
838 if args.limit == Some(0) {
839 return Err(ToolError("read limit must be positive".into()));
840 }
841 Ok(())
842}
843
844fn validate_process_args(value: &str, timeout_seconds: Option<u64>) -> Result<(), ToolError> {
845 if value.trim().is_empty() {
846 return Err(ToolError("command or prompt must be non-empty".into()));
847 }
848 if timeout_seconds == Some(0) {
849 return Err(ToolError("timeout_seconds must be positive".into()));
850 }
851 Ok(())
852}
853
854fn validate_relative(path: &Path) -> Result<(), ToolError> {
855 if path.as_os_str().is_empty() || path.is_absolute() {
856 return Err(ToolError("path must be non-empty and relative".into()));
857 }
858 for component in path.components() {
859 if matches!(
860 component,
861 Component::ParentDir | Component::RootDir | Component::Prefix(_)
862 ) {
863 return Err(ToolError(
864 "parent traversal and absolute paths are not allowed".into(),
865 ));
866 }
867 }
868 Ok(())
869}
870
871static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
872
873fn open_workspace(workspace: &Path) -> Result<Dir, ToolError> {
874 Dir::open_ambient_dir(workspace, ambient_authority())
875 .map_err(|error| ToolError(format!("open workspace capability: {error}")))
876}
877
878fn unique_temporary_path(parent: &Path) -> PathBuf {
879 let id = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
880 parent.join(format!(".scv-write-{}-{id}.tmp", std::process::id()))
881}
882
883fn map_cap_error(action: &str, path: &str, error: std::io::Error) -> ToolError {
884 ToolError(format!(
885 "{action} {path}: {error}; path must remain within workspace"
886 ))
887}
888
889fn is_secret_like(path: &Path) -> bool {
890 path.components().any(|component| {
891 let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
892 value == ".env"
893 || value.starts_with(".env.")
894 || value.contains("credential")
895 || value.contains("private_key")
896 || value.ends_with(".pem")
897 || value.ends_with(".key")
898 })
899}
900
901fn bounded(value: &str, max_chars: usize) -> String {
902 let mut output: String = value.chars().take(max_chars).collect();
903 if value.chars().count() > max_chars {
904 output.push('…');
905 }
906 output
907}
908
909#[cfg(test)]
910mod tests {
911 use std::os::unix::fs::{PermissionsExt, symlink};
912
913 use super::*;
914
915 #[test]
916 fn rejects_parent_traversal() {
917 assert!(validate_relative(Path::new("../secret")).is_err());
918 assert!(validate_relative(Path::new("/etc/passwd")).is_err());
919 }
920
921 #[test]
922 fn detects_secret_like_paths() {
923 assert!(is_secret_like(Path::new(".env")));
924 assert!(is_secret_like(Path::new("keys/id.pem")));
925 assert!(!is_secret_like(Path::new("src/main.rs")));
926 }
927
928 #[tokio::test]
929 async fn read_is_contained_and_bounded() {
930 let directory = tempfile::tempdir().unwrap();
931 std::fs::write(directory.path().join("hello.txt"), "abcdef").unwrap();
932 let tool = ReadTool { max_bytes: 3 };
933 let output = tool
934 .execute(
935 json!({"path":"hello.txt"}),
936 ToolContext {
937 workspace: directory.path().canonicalize().unwrap(),
938 cancellation: tokio_util::sync::CancellationToken::new(),
939 },
940 )
941 .await
942 .unwrap();
943 assert!(output.truncated);
944 assert!(output.content.contains("abc"));
945 }
946
947 #[tokio::test]
948 async fn read_rejects_symlink_escape() {
949 let workspace = tempfile::tempdir().unwrap();
950 let outside = tempfile::tempdir().unwrap();
951 std::fs::write(outside.path().join("secret"), "nope").unwrap();
952 symlink(outside.path(), workspace.path().join("escape")).unwrap();
953 let tool = ReadTool { max_bytes: 100 };
954 let result = tool
955 .execute(
956 json!({"path":"escape/secret"}),
957 ToolContext {
958 workspace: workspace.path().canonicalize().unwrap(),
959 cancellation: tokio_util::sync::CancellationToken::new(),
960 },
961 )
962 .await;
963 assert!(result.unwrap_err().to_string().contains("workspace"));
964 }
965
966 #[tokio::test]
967 async fn write_is_atomic_and_checks_hash() {
968 let workspace = tempfile::tempdir().unwrap();
969 let root = workspace.path().canonicalize().unwrap();
970 let tool = WriteTool { max_bytes: 100 };
971 tool.execute(
972 json!({"path":"file.txt","content":"first","mode":"create"}),
973 ToolContext {
974 workspace: root.clone(),
975 cancellation: tokio_util::sync::CancellationToken::new(),
976 },
977 )
978 .await
979 .unwrap();
980 let hash = format!("{:x}", Sha256::digest(b"first"));
981 tool.execute(
982 json!({"path":"file.txt","content":"second","mode":"replace","expected_sha256":hash}),
983 ToolContext {
984 workspace: root.clone(),
985 cancellation: tokio_util::sync::CancellationToken::new(),
986 },
987 )
988 .await
989 .unwrap();
990 assert_eq!(
991 std::fs::read_to_string(root.join("file.txt")).unwrap(),
992 "second"
993 );
994 let result = tool
995 .execute(
996 json!({"path":"file.txt","content":"third","mode":"replace","expected_sha256":"deadbeef"}),
997 ToolContext {
998 workspace: root,
999 cancellation: tokio_util::sync::CancellationToken::new(),
1000 },
1001 )
1002 .await;
1003 assert!(result.unwrap_err().to_string().contains("changed"));
1004 }
1005
1006 #[tokio::test]
1007 async fn write_rejects_symlink_escape() {
1008 let workspace = tempfile::tempdir().unwrap();
1009 let outside = tempfile::tempdir().unwrap();
1010 symlink(outside.path(), workspace.path().join("escape")).unwrap();
1011 let tool = WriteTool { max_bytes: 100 };
1012 let result = tool
1013 .execute(
1014 json!({"path":"escape/file.txt","content":"nope","mode":"create"}),
1015 ToolContext {
1016 workspace: workspace.path().canonicalize().unwrap(),
1017 cancellation: tokio_util::sync::CancellationToken::new(),
1018 },
1019 )
1020 .await;
1021 assert!(result.unwrap_err().to_string().contains("workspace"));
1022 assert!(!outside.path().join("file.txt").exists());
1023 }
1024
1025 #[tokio::test]
1026 async fn bash_timeout_terminates_the_process() {
1027 let workspace = tempfile::tempdir().unwrap();
1028 let tool = BashTool {
1029 timeout: Duration::from_millis(50),
1030 output_limit: 100,
1031 };
1032 let started = std::time::Instant::now();
1033 let output = tool
1034 .execute(
1035 json!({"command":"sleep 5"}),
1036 ToolContext {
1037 workspace: workspace.path().canonicalize().unwrap(),
1038 cancellation: tokio_util::sync::CancellationToken::new(),
1039 },
1040 )
1041 .await
1042 .unwrap();
1043 assert!(output.is_error);
1044 assert!(started.elapsed() < Duration::from_secs(3));
1045 }
1046
1047 #[tokio::test]
1048 async fn bash_output_is_bounded_and_reports_truncation() {
1049 let workspace = tempfile::tempdir().unwrap();
1050 let tool = BashTool {
1051 timeout: Duration::from_secs(2),
1052 output_limit: 8,
1053 };
1054 let output = tool
1055 .execute(
1056 json!({"command":"printf 12345678901234567890"}),
1057 ToolContext {
1058 workspace: workspace.path().canonicalize().unwrap(),
1059 cancellation: tokio_util::sync::CancellationToken::new(),
1060 },
1061 )
1062 .await
1063 .unwrap();
1064 assert!(output.truncated);
1065 assert!(output.content.contains("12345678"));
1066 assert!(!output.content.contains("123456789"));
1067 }
1068
1069 #[tokio::test]
1070 async fn bash_cancellation_terminates_the_process_group() {
1071 let workspace = tempfile::tempdir().unwrap();
1072 let tool = BashTool {
1073 timeout: Duration::from_secs(30),
1074 output_limit: 100,
1075 };
1076 let cancellation = tokio_util::sync::CancellationToken::new();
1077 let cancel = cancellation.clone();
1078 let started = std::time::Instant::now();
1079 let execution = tokio::spawn(async move {
1080 tool.execute(
1081 json!({"command":"sleep 30"}),
1082 ToolContext {
1083 workspace: workspace.path().canonicalize().unwrap(),
1084 cancellation,
1085 },
1086 )
1087 .await
1088 });
1089 tokio::time::sleep(Duration::from_millis(50)).await;
1090 cancel.cancel();
1091 let error = execution.await.unwrap().unwrap_err();
1092 assert!(error.to_string().contains("cancelled"));
1093 assert!(started.elapsed() < Duration::from_secs(3));
1094 }
1095
1096 #[tokio::test]
1097 async fn background_descendant_cannot_hold_output_pipes_open() {
1098 let workspace = tempfile::tempdir().unwrap();
1099 let root = workspace.path().canonicalize().unwrap();
1100 let tool = BashTool {
1101 timeout: Duration::from_secs(5),
1102 output_limit: 100,
1103 };
1104 let started = std::time::Instant::now();
1105 let output = tool
1106 .execute(
1107 json!({"command":"sleep 30 & echo $! > background.pid; exit 0"}),
1108 ToolContext {
1109 workspace: root.clone(),
1110 cancellation: tokio_util::sync::CancellationToken::new(),
1111 },
1112 )
1113 .await
1114 .unwrap();
1115 assert!(!output.is_error);
1116 assert!(started.elapsed() < Duration::from_secs(3));
1117 let pid: i32 = std::fs::read_to_string(root.join("background.pid"))
1118 .unwrap()
1119 .trim()
1120 .parse()
1121 .unwrap();
1122 for _ in 0..20 {
1123 if unsafe { libc::kill(pid, 0) } != 0 {
1124 return;
1125 }
1126 tokio::time::sleep(Duration::from_millis(10)).await;
1127 }
1128 panic!("background descendant {pid} survived tool completion");
1129 }
1130
1131 #[tokio::test]
1132 async fn cancellation_kills_a_term_ignoring_descendant() {
1133 let workspace = tempfile::tempdir().unwrap();
1134 let root = workspace.path().canonicalize().unwrap();
1135 let tool = BashTool {
1136 timeout: Duration::from_secs(30),
1137 output_limit: 100,
1138 };
1139 let cancellation = tokio_util::sync::CancellationToken::new();
1140 let cancel = cancellation.clone();
1141 let command_root = root.clone();
1142 let execution = tokio::spawn(async move {
1143 tool.execute(
1144 json!({"command":"trap '' TERM; (trap '' TERM; sleep 30) & echo $! > stubborn.pid; wait"}),
1145 ToolContext {
1146 workspace: command_root,
1147 cancellation,
1148 },
1149 )
1150 .await
1151 });
1152 let pid_path = root.join("stubborn.pid");
1153 let mut descendant_pid = None;
1154 for _ in 0..100 {
1155 descendant_pid = std::fs::read_to_string(&pid_path)
1156 .ok()
1157 .and_then(|value| value.trim().parse::<i32>().ok());
1158 if descendant_pid.is_some() {
1159 break;
1160 }
1161 tokio::time::sleep(Duration::from_millis(10)).await;
1162 }
1163 let pid = descendant_pid.expect("command did not report its descendant pid");
1164 let started = std::time::Instant::now();
1165 cancel.cancel();
1166 let error = execution.await.unwrap().unwrap_err();
1167 assert!(error.to_string().contains("cancelled"));
1168 assert!(started.elapsed() < Duration::from_secs(3));
1169 for _ in 0..20 {
1170 if unsafe { libc::kill(pid, 0) } != 0 {
1171 return;
1172 }
1173 tokio::time::sleep(Duration::from_millis(10)).await;
1174 }
1175 panic!("TERM-ignoring descendant {pid} survived cancellation");
1176 }
1177
1178 #[tokio::test]
1179 async fn native_agent_preserves_argument_boundaries() {
1180 let workspace = tempfile::tempdir().unwrap();
1181 let executable = workspace.path().join("fake-agent");
1182 std::fs::write(&executable, "#!/bin/bash\npwd\nprintf '%s\\n' \"$@\"\n").unwrap();
1183 let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
1184 permissions.set_mode(0o755);
1185 std::fs::set_permissions(&executable, permissions).unwrap();
1186 let tool = NativeAgentTool::new(
1187 "agent_fake".into(),
1188 AgentAdapterConfig {
1189 command: executable.display().to_string(),
1190 args: vec!["--fixed".into()],
1191 environment: Vec::new(),
1192 },
1193 Duration::from_secs(2),
1194 1024,
1195 );
1196 let output = tool
1197 .execute(
1198 json!({"prompt":"hello; echo unsafe"}),
1199 ToolContext {
1200 workspace: workspace.path().canonicalize().unwrap(),
1201 cancellation: tokio_util::sync::CancellationToken::new(),
1202 },
1203 )
1204 .await
1205 .unwrap();
1206 assert!(output.content.contains("--fixed"));
1207 assert!(output.content.contains("hello; echo unsafe"));
1208 assert!(
1209 output
1210 .content
1211 .contains(&workspace.path().display().to_string())
1212 );
1213 }
1214
1215 #[tokio::test]
1216 async fn native_agent_uses_instance_private_environment() {
1217 let workspace = tempfile::tempdir().unwrap();
1218 let home = workspace.path().join("private-home");
1219 let executable = workspace.path().join("fake-agent");
1220 std::fs::write(
1221 &executable,
1222 "#!/bin/bash\nprintf '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",
1223 )
1224 .unwrap();
1225 let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
1226 permissions.set_mode(0o755);
1227 std::fs::set_permissions(&executable, permissions).unwrap();
1228 let tool = NativeAgentTool::new(
1229 "agent_codex".into(),
1230 AgentAdapterConfig {
1231 command: executable.display().to_string(),
1232 args: Vec::new(),
1233 environment: vec![
1234 ("HOME".into(), home.clone().into()),
1235 ("SCV_HOME".into(), home.clone().into()),
1236 ("CODEX_HOME".into(), home.join("codex").into()),
1237 ],
1238 },
1239 Duration::from_secs(2),
1240 1024,
1241 );
1242 let output = tool
1243 .execute(
1244 json!({"prompt":"print environment"}),
1245 ToolContext {
1246 workspace: workspace.path().canonicalize().unwrap(),
1247 cancellation: tokio_util::sync::CancellationToken::new(),
1248 },
1249 )
1250 .await
1251 .unwrap();
1252 assert!(output.content.contains(&format!("HOME={}", home.display())));
1253 assert!(
1254 output
1255 .content
1256 .contains(&format!("CODEX_HOME={}/codex", home.display()))
1257 );
1258 assert!(output.content.contains("SCV_CONFIG=unset"));
1259 assert!(output.content.contains("OPENAI_API_KEY=unset"));
1260 assert!(output.content.contains("CODEX_API_KEY=unset"));
1261 }
1262}