Skip to main content

scv_tools/delegate/
request.rs

1//! The arguments every `agent_*` call takes, and their validation.
2
3use std::path::{Path, PathBuf};
4
5use scv_core::ToolError;
6use serde::Deserialize;
7
8#[derive(Deserialize)]
9#[serde(deny_unknown_fields)]
10pub(crate) struct AgentArgs {
11    pub(crate) prompt: String,
12    pub(crate) timeout_seconds: Option<u64>,
13    #[serde(default, deserialize_with = "blank_as_none")]
14    pub(crate) session: Option<String>,
15    #[serde(default, deserialize_with = "blank_as_none")]
16    pub(crate) cwd: Option<String>,
17    #[serde(default, deserialize_with = "blank_as_none")]
18    pub(crate) model: Option<String>,
19    #[serde(default, deserialize_with = "blank_as_none")]
20    pub(crate) effort: Option<String>,
21}
22
23/// Models often send an optional string they mean to leave unset as `""`, so
24/// a blank value selects the default rather than failing the call.
25fn blank_as_none<'de, D: serde::Deserializer<'de>>(
26    deserializer: D,
27) -> Result<Option<String>, D::Error> {
28    let value = Option::<String>::deserialize(deserializer)?;
29    Ok(value.filter(|value| !value.trim().is_empty()))
30}
31
32/// Longest `cwd` argument accepted, in bytes.
33pub(crate) const MAX_AGENT_CWD_BYTES: usize = 4096;
34
35pub(crate) fn validate_agent_cwd(cwd: &str) -> Result<(), ToolError> {
36    if cwd.trim().is_empty() || cwd.len() > MAX_AGENT_CWD_BYTES || cwd.contains('\0') {
37        return Err(ToolError::invalid_arguments(format!(
38            "cwd must be a non-empty directory path of at most {MAX_AGENT_CWD_BYTES} bytes"
39        )));
40    }
41    Ok(())
42}
43
44/// Resolve a requested agent directory against the workspace. Resolution
45/// follows symlinks, so a link pointing outside the workspace is refused
46/// rather than trusted by name.
47pub(crate) fn resolve_agent_cwd(workspace: &Path, cwd: Option<&str>) -> Result<PathBuf, ToolError> {
48    let root = std::fs::canonicalize(workspace)
49        .map_err(|error| ToolError::failed(format!("resolve workspace: {error}")))?;
50    let Some(cwd) = cwd else {
51        return Ok(root);
52    };
53    validate_agent_cwd(cwd)?;
54    let resolved = std::fs::canonicalize(root.join(cwd))
55        .map_err(|error| ToolError::invalid_arguments(format!("cwd {cwd:?}: {error}")))?;
56    if !resolved.starts_with(&root) {
57        return Err(ToolError::invalid_arguments(format!(
58            "cwd {cwd:?} is outside the workspace"
59        )));
60    }
61    if !resolved.is_dir() {
62        return Err(ToolError::invalid_arguments(format!(
63            "cwd {cwd:?} is not a directory"
64        )));
65    }
66    Ok(resolved)
67}
68
69/// Effort values the built-in adapters accept.
70pub const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
71
72/// Model names are passed as one argument, so only reject values that could
73/// read as a flag, name an `@file` argument, or carry unexpected characters.
74pub fn valid_model_name(value: &str) -> bool {
75    !value.is_empty()
76        && value.len() <= 128
77        && !value.starts_with(['-', '@'])
78        && value
79            .chars()
80            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
81}
82
83/// Whether `value` is one of [`AGENT_EFFORTS`].
84pub fn valid_effort(value: &str) -> bool {
85    AGENT_EFFORTS.contains(&value)
86}