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(crate) max_bytes: u64,
71 pub(crate) outbox: PathBuf,
73 pub(crate) denied: Vec<PathBuf>,
75 pub(crate) 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(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(©)
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(©);
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 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
223fn 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 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;