Skip to main content

scv_tools/builtin/
chat_attach.rs

1//! `chat_attach`: send a file with a chat session's reply.
2//!
3//! The tool checks the file, copies it into the channels' media outbox, and
4//! reports the copy; the chat client that owns the session sends it after
5//! the reply text and sends nothing from anywhere else. Because model input
6//! can carry injected instructions, the tool refuses anything that is not a
7//! regular file, anything over the size limit, and every known secret
8//! location, judged after symlinks resolve. This stops a model from mailing
9//! out keys by path; a model with a shell can still copy data elsewhere, so
10//! it is a guard, not a sandbox.
11
12use std::path::{Path, PathBuf};
13
14use async_trait::async_trait;
15use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRisk, ToolSpec};
16use scv_protocol::{CHAT_ATTACH_TOOL, ReplyAttachment};
17use serde::Deserialize;
18use serde_json::{Value, json};
19
20/// Largest caption, in bytes.
21const MAX_CAPTION_BYTES: usize = 1024;
22
23/// Paths under the user's home that hold credentials, keys, or browser
24/// profiles.
25const HOME_SECRETS: &[&str] = &[
26    ".ssh",
27    ".gnupg",
28    ".aws",
29    ".azure",
30    ".kube",
31    ".docker",
32    ".netrc",
33    ".git-credentials",
34    ".npmrc",
35    ".pypirc",
36    ".cargo/credentials",
37    ".cargo/credentials.toml",
38    ".config/gh",
39    ".config/gcloud",
40    ".config/hub",
41    ".config/google-chrome",
42    ".config/chromium",
43    ".mozilla",
44    ".password-store",
45    ".local/share/keyrings",
46    ".codex",
47    ".claude",
48    ".claude.json",
49    ".grok",
50    ".scv",
51];
52
53/// System paths that hold host secrets or are not files.
54const SYSTEM_SECRETS: &[&str] = &[
55    "/etc/shadow",
56    "/etc/gshadow",
57    "/etc/ssh",
58    "/etc/sudoers",
59    "/etc/sudoers.d",
60    "/root",
61    "/proc",
62    "/sys",
63    "/dev",
64];
65
66/// Where `chat_attach` may read from, and where its copies go.
67#[derive(Debug, Clone)]
68pub struct ChatAttachConfig {
69    /// Largest file accepted.
70    pub(crate) max_bytes: u64,
71    /// Private directory the checked copies are written to.
72    pub(crate) outbox: PathBuf,
73    /// Refused, with everything beneath them.
74    pub(crate) denied: Vec<PathBuf>,
75    /// Allowed even beneath a denied path, such as the media chat users sent,
76    /// which lives in the SCV instance.
77    pub(crate) allowed: Vec<PathBuf>,
78}
79
80impl ChatAttachConfig {
81    /// The standard rule: the SCV instance `scv_home` (its settings,
82    /// credentials, agent homes, and state) except `allowed`, the credential
83    /// and key locations under `home`, and host secrets.
84    pub fn standard(
85        home: Option<&Path>,
86        scv_home: &Path,
87        outbox: PathBuf,
88        allowed: Vec<PathBuf>,
89        max_bytes: u64,
90    ) -> Self {
91        let mut denied = vec![scv_home.to_path_buf()];
92        if let Some(home) = home {
93            denied.extend(HOME_SECRETS.iter().map(|path| home.join(path)));
94        }
95        denied.extend(SYSTEM_SECRETS.iter().map(PathBuf::from));
96        Self {
97            max_bytes,
98            outbox,
99            denied,
100            allowed,
101        }
102    }
103
104    /// Check `path` and copy it into the outbox, reporting the copy. The
105    /// file is opened without following a final symlink and re-checked
106    /// through the open handle, so it cannot be swapped after the check.
107    pub(crate) fn attach(
108        &self,
109        workspace: &Path,
110        path: &str,
111    ) -> Result<ReplyAttachment, ToolError> {
112        use std::io::Read as _;
113        use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
114        let mut attached = self.check(workspace, path)?;
115        let mut source = std::fs::OpenOptions::new()
116            .read(true)
117            .custom_flags(libc::O_NOFOLLOW)
118            .open(&attached.path)
119            .map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
120        let metadata = source
121            .metadata()
122            .map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
123        if !metadata.is_file() || metadata.len() > self.max_bytes {
124            return Err(ToolError::failed(format!(
125                "cannot attach {path}: the file changed"
126            )));
127        }
128        std::fs::DirBuilder::new()
129            .recursive(true)
130            .mode(0o700)
131            .create(&self.outbox)
132            .map_err(|error| {
133                ToolError::failed(format!("cannot prepare the chat outbox: {error}"))
134            })?;
135        let _ = std::fs::set_permissions(&self.outbox, std::fs::Permissions::from_mode(0o700));
136        let outbox = std::fs::canonicalize(&self.outbox).map_err(|error| {
137            ToolError::failed(format!("cannot prepare the chat outbox: {error}"))
138        })?;
139        let copy = outbox.join(format!(
140            "{}-{}",
141            &uuid::Uuid::new_v4().simple().to_string()[..12],
142            attached.name
143        ));
144        let mut target = std::fs::OpenOptions::new()
145            .write(true)
146            .create_new(true)
147            .mode(0o600)
148            .custom_flags(libc::O_NOFOLLOW)
149            .open(&copy)
150            .map_err(|error| ToolError::failed(format!("cannot copy {path}: {error}")))?;
151        let copied = std::io::copy(&mut (&mut source).take(self.max_bytes + 1), &mut target)
152            .map_err(|error| ToolError::failed(format!("cannot copy {path}: {error}")))?;
153        if copied > self.max_bytes {
154            let _ = std::fs::remove_file(&copy);
155            return Err(ToolError::failed(format!(
156                "cannot attach {path}: the file changed"
157            )));
158        }
159        attached.path = copy.display().to_string();
160        attached.size = copied;
161        Ok(attached)
162    }
163
164    /// Check `path` (relative paths resolve from `workspace`) and describe
165    /// the file to send.
166    pub(crate) fn check(&self, workspace: &Path, path: &str) -> Result<ReplyAttachment, ToolError> {
167        let requested = Path::new(path);
168        let joined = if requested.is_absolute() {
169            requested.to_path_buf()
170        } else {
171            workspace.join(requested)
172        };
173        let resolved = std::fs::canonicalize(&joined)
174            .map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
175        if self.is_denied(&resolved) || has_secret_name(&resolved) {
176            return Err(ToolError::failed(format!(
177                "cannot attach {path}: it is in a location that holds credentials or keys"
178            )));
179        }
180        let metadata = std::fs::metadata(&resolved)
181            .map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
182        if !metadata.is_file() {
183            return Err(ToolError::failed(format!(
184                "cannot attach {path}: not a regular file"
185            )));
186        }
187        if metadata.len() == 0 {
188            return Err(ToolError::failed(format!(
189                "cannot attach {path}: the file is empty"
190            )));
191        }
192        if metadata.len() > self.max_bytes {
193            return Err(ToolError::limit(format!(
194                "cannot attach {path}: {} bytes is over the {} byte limit",
195                metadata.len(),
196                self.max_bytes
197            )));
198        }
199        let name = resolved.file_name().map_or_else(
200            || "file".to_owned(),
201            |name| name.to_string_lossy().into_owned(),
202        );
203        Ok(ReplyAttachment {
204            path: resolved.display().to_string(),
205            name,
206            mime: String::new(),
207            size: metadata.len(),
208            caption: String::new(),
209        })
210    }
211
212    fn is_denied(&self, resolved: &Path) -> bool {
213        let under = |roots: &[PathBuf]| {
214            roots.iter().any(|root| {
215                resolved.starts_with(root)
216                    || std::fs::canonicalize(root).is_ok_and(|root| resolved.starts_with(root))
217            })
218        };
219        under(&self.denied) && !under(&self.allowed)
220    }
221}
222
223/// File names that are secrets wherever they are.
224fn has_secret_name(path: &Path) -> bool {
225    path.components().any(|component| {
226        let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
227        value == ".env"
228            || value.starts_with(".env.")
229            || value.contains("credential")
230            || value.contains("private_key")
231            || value.ends_with(".pem")
232            || value.ends_with(".key")
233            || value.ends_with(".p12")
234            || value.ends_with(".pfx")
235            || value.ends_with(".kdbx")
236            || value.starts_with("id_rsa")
237            || value.starts_with("id_ecdsa")
238            || value.starts_with("id_ed25519")
239            || value.starts_with("id_dsa")
240    })
241}
242
243pub(crate) struct ChatAttachTool {
244    pub(crate) config: ChatAttachConfig,
245}
246
247#[derive(Deserialize)]
248#[serde(deny_unknown_fields)]
249struct Args {
250    path: String,
251    #[serde(default)]
252    caption: Option<String>,
253}
254
255fn parse(arguments: &Value) -> Result<Args, ToolError> {
256    let args: Args = serde_json::from_value(arguments.clone()).map_err(|error| {
257        ToolError::invalid_arguments(format!("invalid chat_attach arguments: {error}"))
258    })?;
259    if args.path.trim().is_empty() {
260        return Err(ToolError::invalid_arguments("path must not be empty"));
261    }
262    if args
263        .caption
264        .as_ref()
265        .is_some_and(|caption| caption.len() > MAX_CAPTION_BYTES)
266    {
267        return Err(ToolError::invalid_arguments(format!(
268            "caption is longer than {MAX_CAPTION_BYTES} bytes"
269        )));
270    }
271    Ok(args)
272}
273
274#[async_trait]
275impl Tool for ChatAttachTool {
276    fn spec(&self) -> ToolSpec {
277        ToolSpec {
278            name: CHAT_ATTACH_TOOL.into(),
279            description: format!(
280                "Send a file to the user in this chat, such as an image, a PDF, or a log, after \
281                 your reply text. Use it when the user asks for a file or a picture says more \
282                 than words. Images arrive as pictures, anything else as a file. The file must \
283                 be a regular file of at most {} MiB; files in credential or key locations are \
284                 refused. Call it once per file.",
285                self.config.max_bytes / (1024 * 1024)
286            ),
287            parameters: json!({
288                "type":"object",
289                "properties":{
290                    "path":{"type":"string","description":"Absolute path, or a path relative to the workspace"},
291                    "caption":{"type":"string","description":"Short text sent with the file"}
292                },
293                "required":["path"],
294                "additionalProperties":false
295            }),
296        }
297    }
298
299    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
300        parse(arguments)?;
301        // The file leaves the host.
302        Ok(ToolRisk::Network)
303    }
304
305    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
306        let args = parse(arguments)?;
307        Ok(format!("Send {} to the chat", args.path))
308    }
309
310    async fn execute(
311        &self,
312        arguments: Value,
313        context: ToolContext,
314    ) -> Result<ToolOutput, ToolError> {
315        let args = parse(&arguments)?;
316        let config = self.config.clone();
317        let workspace = context.workspace.clone();
318        let path = args.path.clone();
319        let mut attached = tokio::task::spawn_blocking(move || config.attach(&workspace, &path))
320            .await
321            .map_err(|error| ToolError::failed(format!("chat_attach failed: {error}")))??;
322        attached.caption = args.caption.unwrap_or_default().trim().to_owned();
323        Ok(ToolOutput::success(
324            json!({
325                "attached": attached,
326                "note": "The file is sent after your reply text."
327            })
328            .to_string(),
329        ))
330    }
331}
332
333#[cfg(test)]
334mod tests;