1use 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
20const MAX_CAPTION_BYTES: usize = 1024;
22
23const 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
53const 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#[derive(Debug, Clone)]
68pub struct ChatAttachConfig {
69 pub max_bytes: u64,
71 pub outbox: PathBuf,
73 pub denied: Vec<PathBuf>,
75 pub allowed: Vec<PathBuf>,
78}
79
80impl ChatAttachConfig {
81 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 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(©)
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(©);
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 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
212fn 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 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 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}