use std::path::{Path, PathBuf};
use async_trait::async_trait;
use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRisk, ToolSpec};
use scv_protocol::{CHAT_ATTACH_TOOL, ReplyAttachment};
use serde::Deserialize;
use serde_json::{Value, json};
const MAX_CAPTION_BYTES: usize = 1024;
const HOME_SECRETS: &[&str] = &[
".ssh",
".gnupg",
".aws",
".azure",
".kube",
".docker",
".netrc",
".git-credentials",
".npmrc",
".pypirc",
".cargo/credentials",
".cargo/credentials.toml",
".config/gh",
".config/gcloud",
".config/hub",
".config/google-chrome",
".config/chromium",
".mozilla",
".password-store",
".local/share/keyrings",
".codex",
".claude",
".claude.json",
".grok",
".scv",
];
const SYSTEM_SECRETS: &[&str] = &[
"/etc/shadow",
"/etc/gshadow",
"/etc/ssh",
"/etc/sudoers",
"/etc/sudoers.d",
"/root",
"/proc",
"/sys",
"/dev",
];
#[derive(Debug, Clone)]
pub struct ChatAttachConfig {
pub(crate) max_bytes: u64,
pub(crate) outbox: PathBuf,
pub(crate) denied: Vec<PathBuf>,
pub(crate) allowed: Vec<PathBuf>,
}
impl ChatAttachConfig {
pub fn standard(
home: Option<&Path>,
scv_home: &Path,
outbox: PathBuf,
allowed: Vec<PathBuf>,
max_bytes: u64,
) -> Self {
let mut denied = vec![scv_home.to_path_buf()];
if let Some(home) = home {
denied.extend(HOME_SECRETS.iter().map(|path| home.join(path)));
}
denied.extend(SYSTEM_SECRETS.iter().map(PathBuf::from));
Self {
max_bytes,
outbox,
denied,
allowed,
}
}
pub(crate) fn attach(
&self,
workspace: &Path,
path: &str,
) -> Result<ReplyAttachment, ToolError> {
use std::io::Read as _;
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
let mut attached = self.check(workspace, path)?;
let mut source = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&attached.path)
.map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
let metadata = source
.metadata()
.map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
if !metadata.is_file() || metadata.len() > self.max_bytes {
return Err(ToolError::failed(format!(
"cannot attach {path}: the file changed"
)));
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&self.outbox)
.map_err(|error| {
ToolError::failed(format!("cannot prepare the chat outbox: {error}"))
})?;
let _ = std::fs::set_permissions(&self.outbox, std::fs::Permissions::from_mode(0o700));
let outbox = std::fs::canonicalize(&self.outbox).map_err(|error| {
ToolError::failed(format!("cannot prepare the chat outbox: {error}"))
})?;
let copy = outbox.join(format!(
"{}-{}",
&uuid::Uuid::new_v4().simple().to_string()[..12],
attached.name
));
let mut target = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(©)
.map_err(|error| ToolError::failed(format!("cannot copy {path}: {error}")))?;
let copied = std::io::copy(&mut (&mut source).take(self.max_bytes + 1), &mut target)
.map_err(|error| ToolError::failed(format!("cannot copy {path}: {error}")))?;
if copied > self.max_bytes {
let _ = std::fs::remove_file(©);
return Err(ToolError::failed(format!(
"cannot attach {path}: the file changed"
)));
}
attached.path = copy.display().to_string();
attached.size = copied;
Ok(attached)
}
pub(crate) fn check(&self, workspace: &Path, path: &str) -> Result<ReplyAttachment, ToolError> {
let requested = Path::new(path);
let joined = if requested.is_absolute() {
requested.to_path_buf()
} else {
workspace.join(requested)
};
let resolved = std::fs::canonicalize(&joined)
.map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
if self.is_denied(&resolved) || has_secret_name(&resolved) {
return Err(ToolError::failed(format!(
"cannot attach {path}: it is in a location that holds credentials or keys"
)));
}
let metadata = std::fs::metadata(&resolved)
.map_err(|error| ToolError::failed(format!("cannot attach {path}: {error}")))?;
if !metadata.is_file() {
return Err(ToolError::failed(format!(
"cannot attach {path}: not a regular file"
)));
}
if metadata.len() == 0 {
return Err(ToolError::failed(format!(
"cannot attach {path}: the file is empty"
)));
}
if metadata.len() > self.max_bytes {
return Err(ToolError::limit(format!(
"cannot attach {path}: {} bytes is over the {} byte limit",
metadata.len(),
self.max_bytes
)));
}
let name = resolved.file_name().map_or_else(
|| "file".to_owned(),
|name| name.to_string_lossy().into_owned(),
);
Ok(ReplyAttachment {
path: resolved.display().to_string(),
name,
mime: String::new(),
size: metadata.len(),
caption: String::new(),
})
}
fn is_denied(&self, resolved: &Path) -> bool {
let under = |roots: &[PathBuf]| {
roots.iter().any(|root| {
resolved.starts_with(root)
|| std::fs::canonicalize(root).is_ok_and(|root| resolved.starts_with(root))
})
};
under(&self.denied) && !under(&self.allowed)
}
}
fn has_secret_name(path: &Path) -> bool {
path.components().any(|component| {
let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
value == ".env"
|| value.starts_with(".env.")
|| value.contains("credential")
|| value.contains("private_key")
|| value.ends_with(".pem")
|| value.ends_with(".key")
|| value.ends_with(".p12")
|| value.ends_with(".pfx")
|| value.ends_with(".kdbx")
|| value.starts_with("id_rsa")
|| value.starts_with("id_ecdsa")
|| value.starts_with("id_ed25519")
|| value.starts_with("id_dsa")
})
}
pub(crate) struct ChatAttachTool {
pub(crate) config: ChatAttachConfig,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Args {
path: String,
#[serde(default)]
caption: Option<String>,
}
fn parse(arguments: &Value) -> Result<Args, ToolError> {
let args: Args = serde_json::from_value(arguments.clone()).map_err(|error| {
ToolError::invalid_arguments(format!("invalid chat_attach arguments: {error}"))
})?;
if args.path.trim().is_empty() {
return Err(ToolError::invalid_arguments("path must not be empty"));
}
if args
.caption
.as_ref()
.is_some_and(|caption| caption.len() > MAX_CAPTION_BYTES)
{
return Err(ToolError::invalid_arguments(format!(
"caption is longer than {MAX_CAPTION_BYTES} bytes"
)));
}
Ok(args)
}
#[async_trait]
impl Tool for ChatAttachTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: CHAT_ATTACH_TOOL.into(),
description: format!(
"Send a file to the user in this chat, such as an image, a PDF, or a log, after \
your reply text. Use it when the user asks for a file or a picture says more \
than words. Images arrive as pictures, anything else as a file. The file must \
be a regular file of at most {} MiB; files in credential or key locations are \
refused. Call it once per file.",
self.config.max_bytes / (1024 * 1024)
),
parameters: json!({
"type":"object",
"properties":{
"path":{"type":"string","description":"Absolute path, or a path relative to the workspace"},
"caption":{"type":"string","description":"Short text sent with the file"}
},
"required":["path"],
"additionalProperties":false
}),
}
}
fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
parse(arguments)?;
Ok(ToolRisk::Network)
}
fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
let args = parse(arguments)?;
Ok(format!("Send {} to the chat", args.path))
}
async fn execute(
&self,
arguments: Value,
context: ToolContext,
) -> Result<ToolOutput, ToolError> {
let args = parse(&arguments)?;
let config = self.config.clone();
let workspace = context.workspace.clone();
let path = args.path.clone();
let mut attached = tokio::task::spawn_blocking(move || config.attach(&workspace, &path))
.await
.map_err(|error| ToolError::failed(format!("chat_attach failed: {error}")))??;
attached.caption = args.caption.unwrap_or_default().trim().to_owned();
Ok(ToolOutput::success(
json!({
"attached": attached,
"note": "The file is sent after your reply text."
})
.to_string(),
))
}
}
#[cfg(test)]
mod tests;