Skip to main content

scv_tools/
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 max_bytes: u64,
71    /// Private directory the checked copies are written to.
72    pub outbox: PathBuf,
73    /// Refused, with everything beneath them.
74    pub 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 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 fn attach(&self, workspace: &Path, path: &str) -> Result<ReplyAttachment, ToolError> {
108        use std::io::Read as _;
109        use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
110        let mut attached = self.check(workspace, path)?;
111        let mut source = std::fs::OpenOptions::new()
112            .read(true)
113            .custom_flags(libc::O_NOFOLLOW)
114            .open(&attached.path)
115            .map_err(|error| ToolError(format!("cannot attach {path}: {error}")))?;
116        let metadata = source
117            .metadata()
118            .map_err(|error| ToolError(format!("cannot attach {path}: {error}")))?;
119        if !metadata.is_file() || metadata.len() > self.max_bytes {
120            return Err(ToolError(format!("cannot attach {path}: the file changed")));
121        }
122        std::fs::DirBuilder::new()
123            .recursive(true)
124            .mode(0o700)
125            .create(&self.outbox)
126            .map_err(|error| ToolError(format!("cannot prepare the chat outbox: {error}")))?;
127        let _ = std::fs::set_permissions(&self.outbox, std::fs::Permissions::from_mode(0o700));
128        let outbox = std::fs::canonicalize(&self.outbox)
129            .map_err(|error| ToolError(format!("cannot prepare the chat outbox: {error}")))?;
130        let copy = outbox.join(format!(
131            "{}-{}",
132            &uuid::Uuid::new_v4().simple().to_string()[..12],
133            attached.name
134        ));
135        let mut target = std::fs::OpenOptions::new()
136            .write(true)
137            .create_new(true)
138            .mode(0o600)
139            .custom_flags(libc::O_NOFOLLOW)
140            .open(&copy)
141            .map_err(|error| ToolError(format!("cannot copy {path}: {error}")))?;
142        let copied = std::io::copy(&mut (&mut source).take(self.max_bytes + 1), &mut target)
143            .map_err(|error| ToolError(format!("cannot copy {path}: {error}")))?;
144        if copied > self.max_bytes {
145            let _ = std::fs::remove_file(&copy);
146            return Err(ToolError(format!("cannot attach {path}: the file changed")));
147        }
148        attached.path = copy.display().to_string();
149        attached.size = copied;
150        Ok(attached)
151    }
152
153    /// Check `path` (relative paths resolve from `workspace`) and describe
154    /// the file to send.
155    pub fn check(&self, workspace: &Path, path: &str) -> Result<ReplyAttachment, ToolError> {
156        let requested = Path::new(path);
157        let joined = if requested.is_absolute() {
158            requested.to_path_buf()
159        } else {
160            workspace.join(requested)
161        };
162        let resolved = std::fs::canonicalize(&joined)
163            .map_err(|error| ToolError(format!("cannot attach {path}: {error}")))?;
164        if self.is_denied(&resolved) || has_secret_name(&resolved) {
165            return Err(ToolError(format!(
166                "cannot attach {path}: it is in a location that holds credentials or keys"
167            )));
168        }
169        let metadata = std::fs::metadata(&resolved)
170            .map_err(|error| ToolError(format!("cannot attach {path}: {error}")))?;
171        if !metadata.is_file() {
172            return Err(ToolError(format!(
173                "cannot attach {path}: not a regular file"
174            )));
175        }
176        if metadata.len() == 0 {
177            return Err(ToolError(format!(
178                "cannot attach {path}: the file is empty"
179            )));
180        }
181        if metadata.len() > self.max_bytes {
182            return Err(ToolError(format!(
183                "cannot attach {path}: {} bytes is over the {} byte limit",
184                metadata.len(),
185                self.max_bytes
186            )));
187        }
188        let name = resolved.file_name().map_or_else(
189            || "file".to_owned(),
190            |name| name.to_string_lossy().into_owned(),
191        );
192        Ok(ReplyAttachment {
193            path: resolved.display().to_string(),
194            name,
195            mime: String::new(),
196            size: metadata.len(),
197            caption: String::new(),
198        })
199    }
200
201    fn is_denied(&self, resolved: &Path) -> bool {
202        let under = |roots: &[PathBuf]| {
203            roots.iter().any(|root| {
204                resolved.starts_with(root)
205                    || std::fs::canonicalize(root).is_ok_and(|root| resolved.starts_with(root))
206            })
207        };
208        under(&self.denied) && !under(&self.allowed)
209    }
210}
211
212/// File names that are secrets wherever they are.
213fn has_secret_name(path: &Path) -> bool {
214    path.components().any(|component| {
215        let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
216        value == ".env"
217            || value.starts_with(".env.")
218            || value.contains("credential")
219            || value.contains("private_key")
220            || value.ends_with(".pem")
221            || value.ends_with(".key")
222            || value.ends_with(".p12")
223            || value.ends_with(".pfx")
224            || value.ends_with(".kdbx")
225            || value.starts_with("id_rsa")
226            || value.starts_with("id_ecdsa")
227            || value.starts_with("id_ed25519")
228            || value.starts_with("id_dsa")
229    })
230}
231
232pub struct ChatAttachTool {
233    pub config: ChatAttachConfig,
234}
235
236#[derive(Deserialize)]
237#[serde(deny_unknown_fields)]
238struct Args {
239    path: String,
240    #[serde(default)]
241    caption: Option<String>,
242}
243
244fn parse(arguments: &Value) -> Result<Args, ToolError> {
245    let args: Args = serde_json::from_value(arguments.clone())
246        .map_err(|error| ToolError(format!("invalid chat_attach arguments: {error}")))?;
247    if args.path.trim().is_empty() {
248        return Err(ToolError("path must not be empty".into()));
249    }
250    if args
251        .caption
252        .as_ref()
253        .is_some_and(|caption| caption.len() > MAX_CAPTION_BYTES)
254    {
255        return Err(ToolError(format!(
256            "caption is longer than {MAX_CAPTION_BYTES} bytes"
257        )));
258    }
259    Ok(args)
260}
261
262#[async_trait]
263impl Tool for ChatAttachTool {
264    fn spec(&self) -> ToolSpec {
265        ToolSpec {
266            name: CHAT_ATTACH_TOOL.into(),
267            description: format!(
268                "Send a file to the user in this chat, such as an image, a PDF, or a log, after \
269                 your reply text. Use it when the user asks for a file or a picture says more \
270                 than words. Images arrive as pictures, anything else as a file. The file must \
271                 be a regular file of at most {} MiB; files in credential or key locations are \
272                 refused. Call it once per file.",
273                self.config.max_bytes / (1024 * 1024)
274            ),
275            parameters: json!({
276                "type":"object",
277                "properties":{
278                    "path":{"type":"string","description":"Absolute path, or a path relative to the workspace"},
279                    "caption":{"type":"string","description":"Short text sent with the file"}
280                },
281                "required":["path"],
282                "additionalProperties":false
283            }),
284        }
285    }
286
287    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
288        parse(arguments)?;
289        // The file leaves the host.
290        Ok(ToolRisk::Network)
291    }
292
293    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
294        let args = parse(arguments)?;
295        Ok(format!("Send {} to the chat", args.path))
296    }
297
298    async fn execute(
299        &self,
300        arguments: Value,
301        context: ToolContext,
302    ) -> Result<ToolOutput, ToolError> {
303        let args = parse(&arguments)?;
304        let config = self.config.clone();
305        let workspace = context.workspace.clone();
306        let path = args.path.clone();
307        let mut attached = tokio::task::spawn_blocking(move || config.attach(&workspace, &path))
308            .await
309            .map_err(|error| ToolError(format!("chat_attach failed: {error}")))??;
310        attached.caption = args.caption.unwrap_or_default().trim().to_owned();
311        Ok(ToolOutput::success(
312            json!({
313                "attached": attached,
314                "note": "The file is sent after your reply text."
315            })
316            .to_string(),
317        ))
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::os::unix::fs::{PermissionsExt as _, symlink};
325
326    fn config(root: &Path) -> ChatAttachConfig {
327        ChatAttachConfig::standard(
328            Some(&root.join("home")),
329            &root.join("home/.scv"),
330            root.join("home/.scv/state/media/outbox"),
331            vec![root.join("home/.scv/state/media")],
332            1024,
333        )
334    }
335
336    fn file(path: &Path, bytes: &[u8]) {
337        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
338        std::fs::write(path, bytes).unwrap();
339    }
340
341    #[test]
342    fn workspace_files_are_attached_with_their_resolved_path() {
343        let root = tempfile::tempdir().unwrap();
344        let workspace = root.path().join("home/work");
345        file(&workspace.join("out/chart.png"), b"png");
346        let attached = config(root.path())
347            .check(&workspace, "out/chart.png")
348            .unwrap();
349        assert_eq!(attached.name, "chart.png");
350        assert_eq!(attached.size, 3);
351        assert_eq!(
352            Path::new(&attached.path),
353            std::fs::canonicalize(workspace.join("out/chart.png")).unwrap()
354        );
355    }
356
357    #[test]
358    fn secrets_are_refused_even_through_symlinks() {
359        let root = tempfile::tempdir().unwrap();
360        let home = root.path().join("home");
361        let workspace = home.join("work");
362        file(&home.join(".ssh/config"), b"Host x");
363        file(&home.join(".scv/config.toml"), b"[provider]");
364        file(&home.join(".scv/credentials/wechat/default.json"), b"{}");
365        file(&home.join(".cargo/credentials.toml"), b"token");
366        file(&home.join(".config/gh/hosts.yml"), b"token");
367        file(&workspace.join(".env"), b"KEY=1");
368        file(&workspace.join("server.pem"), b"-----");
369        std::fs::create_dir_all(&workspace).unwrap();
370        symlink(home.join(".ssh/config"), workspace.join("innocent.txt")).unwrap();
371        let config = config(root.path());
372        for path in [
373            home.join(".ssh/config").display().to_string(),
374            home.join(".scv/config.toml").display().to_string(),
375            home.join(".scv/credentials/wechat/default.json")
376                .display()
377                .to_string(),
378            home.join(".cargo/credentials.toml").display().to_string(),
379            home.join(".config/gh/hosts.yml").display().to_string(),
380            ".env".into(),
381            "server.pem".into(),
382            "innocent.txt".into(),
383        ] {
384            let error = config.check(&workspace, &path).unwrap_err();
385            assert!(
386                error.0.contains("credentials or keys"),
387                "{path}: {}",
388                error.0
389            );
390        }
391    }
392
393    #[test]
394    fn media_the_user_sent_stays_attachable_inside_the_instance() {
395        let root = tempfile::tempdir().unwrap();
396        let media = root.path().join("home/.scv/state/media/wechat/photo.jpg");
397        file(&media, b"jpg");
398        let attached = config(root.path())
399            .check(root.path(), &media.display().to_string())
400            .unwrap();
401        assert_eq!(attached.name, "photo.jpg");
402    }
403
404    #[test]
405    fn directories_missing_empty_and_oversized_files_are_refused() {
406        let root = tempfile::tempdir().unwrap();
407        let workspace = root.path().join("home/work");
408        std::fs::create_dir_all(workspace.join("dir")).unwrap();
409        file(&workspace.join("empty"), b"");
410        file(&workspace.join("big"), &[0; 2048]);
411        let config = config(root.path());
412        assert!(
413            config
414                .check(&workspace, "dir")
415                .unwrap_err()
416                .0
417                .contains("regular file")
418        );
419        assert!(
420            config
421                .check(&workspace, "empty")
422                .unwrap_err()
423                .0
424                .contains("empty")
425        );
426        assert!(
427            config
428                .check(&workspace, "big")
429                .unwrap_err()
430                .0
431                .contains("limit")
432        );
433        assert!(config.check(&workspace, "missing").is_err());
434    }
435
436    #[tokio::test]
437    async fn the_tool_reports_the_attachment_the_client_reads() {
438        let root = tempfile::tempdir().unwrap();
439        let workspace = root.path().join("home/work");
440        file(&workspace.join("notes.txt"), b"hello");
441        let tool = ChatAttachTool {
442            config: config(root.path()),
443        };
444        let arguments = json!({"path":"notes.txt","caption":" today's notes "});
445        assert_eq!(tool.risk(&arguments).unwrap(), ToolRisk::Network);
446        let output = tool
447            .execute(
448                arguments,
449                ToolContext::new(workspace, tokio_util::sync::CancellationToken::new()),
450            )
451            .await
452            .unwrap();
453        let attached =
454            scv_protocol::reply_attachment(CHAT_ATTACH_TOOL, !output.is_error, &output.content)
455                .unwrap();
456        assert_eq!(attached.name, "notes.txt");
457        assert_eq!(attached.caption, "today's notes");
458        // The client sends a private copy in the outbox, not the original.
459        let copy = Path::new(&attached.path);
460        let outbox =
461            std::fs::canonicalize(root.path().join("home/.scv/state/media/outbox")).unwrap();
462        assert!(copy.starts_with(&outbox), "{}", copy.display());
463        assert_eq!(std::fs::read(copy).unwrap(), b"hello");
464        assert_eq!(
465            std::fs::metadata(copy).unwrap().permissions().mode() & 0o777,
466            0o600
467        );
468        assert!(tool.risk(&json!({"path":"x","extra":1})).is_err());
469    }
470}