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 max_bytes: u64,
pub outbox: PathBuf,
pub denied: Vec<PathBuf>,
pub 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 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(format!("cannot attach {path}: {error}")))?;
let metadata = source
.metadata()
.map_err(|error| ToolError(format!("cannot attach {path}: {error}")))?;
if !metadata.is_file() || metadata.len() > self.max_bytes {
return Err(ToolError(format!("cannot attach {path}: the file changed")));
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&self.outbox)
.map_err(|error| ToolError(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(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(format!("cannot copy {path}: {error}")))?;
let copied = std::io::copy(&mut (&mut source).take(self.max_bytes + 1), &mut target)
.map_err(|error| ToolError(format!("cannot copy {path}: {error}")))?;
if copied > self.max_bytes {
let _ = std::fs::remove_file(©);
return Err(ToolError(format!("cannot attach {path}: the file changed")));
}
attached.path = copy.display().to_string();
attached.size = copied;
Ok(attached)
}
pub 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(format!("cannot attach {path}: {error}")))?;
if self.is_denied(&resolved) || has_secret_name(&resolved) {
return Err(ToolError(format!(
"cannot attach {path}: it is in a location that holds credentials or keys"
)));
}
let metadata = std::fs::metadata(&resolved)
.map_err(|error| ToolError(format!("cannot attach {path}: {error}")))?;
if !metadata.is_file() {
return Err(ToolError(format!(
"cannot attach {path}: not a regular file"
)));
}
if metadata.len() == 0 {
return Err(ToolError(format!(
"cannot attach {path}: the file is empty"
)));
}
if metadata.len() > self.max_bytes {
return Err(ToolError(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 struct ChatAttachTool {
pub 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(format!("invalid chat_attach arguments: {error}")))?;
if args.path.trim().is_empty() {
return Err(ToolError("path must not be empty".into()));
}
if args
.caption
.as_ref()
.is_some_and(|caption| caption.len() > MAX_CAPTION_BYTES)
{
return Err(ToolError(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(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 {
use super::*;
use std::os::unix::fs::{PermissionsExt as _, symlink};
fn config(root: &Path) -> ChatAttachConfig {
ChatAttachConfig::standard(
Some(&root.join("home")),
&root.join("home/.scv"),
root.join("home/.scv/state/media/outbox"),
vec![root.join("home/.scv/state/media")],
1024,
)
}
fn file(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[test]
fn workspace_files_are_attached_with_their_resolved_path() {
let root = tempfile::tempdir().unwrap();
let workspace = root.path().join("home/work");
file(&workspace.join("out/chart.png"), b"png");
let attached = config(root.path())
.check(&workspace, "out/chart.png")
.unwrap();
assert_eq!(attached.name, "chart.png");
assert_eq!(attached.size, 3);
assert_eq!(
Path::new(&attached.path),
std::fs::canonicalize(workspace.join("out/chart.png")).unwrap()
);
}
#[test]
fn secrets_are_refused_even_through_symlinks() {
let root = tempfile::tempdir().unwrap();
let home = root.path().join("home");
let workspace = home.join("work");
file(&home.join(".ssh/config"), b"Host x");
file(&home.join(".scv/config.toml"), b"[provider]");
file(&home.join(".scv/credentials/wechat/default.json"), b"{}");
file(&home.join(".cargo/credentials.toml"), b"token");
file(&home.join(".config/gh/hosts.yml"), b"token");
file(&workspace.join(".env"), b"KEY=1");
file(&workspace.join("server.pem"), b"-----");
std::fs::create_dir_all(&workspace).unwrap();
symlink(home.join(".ssh/config"), workspace.join("innocent.txt")).unwrap();
let config = config(root.path());
for path in [
home.join(".ssh/config").display().to_string(),
home.join(".scv/config.toml").display().to_string(),
home.join(".scv/credentials/wechat/default.json")
.display()
.to_string(),
home.join(".cargo/credentials.toml").display().to_string(),
home.join(".config/gh/hosts.yml").display().to_string(),
".env".into(),
"server.pem".into(),
"innocent.txt".into(),
] {
let error = config.check(&workspace, &path).unwrap_err();
assert!(
error.0.contains("credentials or keys"),
"{path}: {}",
error.0
);
}
}
#[test]
fn media_the_user_sent_stays_attachable_inside_the_instance() {
let root = tempfile::tempdir().unwrap();
let media = root.path().join("home/.scv/state/media/wechat/photo.jpg");
file(&media, b"jpg");
let attached = config(root.path())
.check(root.path(), &media.display().to_string())
.unwrap();
assert_eq!(attached.name, "photo.jpg");
}
#[test]
fn directories_missing_empty_and_oversized_files_are_refused() {
let root = tempfile::tempdir().unwrap();
let workspace = root.path().join("home/work");
std::fs::create_dir_all(workspace.join("dir")).unwrap();
file(&workspace.join("empty"), b"");
file(&workspace.join("big"), &[0; 2048]);
let config = config(root.path());
assert!(
config
.check(&workspace, "dir")
.unwrap_err()
.0
.contains("regular file")
);
assert!(
config
.check(&workspace, "empty")
.unwrap_err()
.0
.contains("empty")
);
assert!(
config
.check(&workspace, "big")
.unwrap_err()
.0
.contains("limit")
);
assert!(config.check(&workspace, "missing").is_err());
}
#[tokio::test]
async fn the_tool_reports_the_attachment_the_client_reads() {
let root = tempfile::tempdir().unwrap();
let workspace = root.path().join("home/work");
file(&workspace.join("notes.txt"), b"hello");
let tool = ChatAttachTool {
config: config(root.path()),
};
let arguments = json!({"path":"notes.txt","caption":" today's notes "});
assert_eq!(tool.risk(&arguments).unwrap(), ToolRisk::Network);
let output = tool
.execute(
arguments,
ToolContext::new(workspace, tokio_util::sync::CancellationToken::new()),
)
.await
.unwrap();
let attached =
scv_protocol::reply_attachment(CHAT_ATTACH_TOOL, !output.is_error, &output.content)
.unwrap();
assert_eq!(attached.name, "notes.txt");
assert_eq!(attached.caption, "today's notes");
let copy = Path::new(&attached.path);
let outbox =
std::fs::canonicalize(root.path().join("home/.scv/state/media/outbox")).unwrap();
assert!(copy.starts_with(&outbox), "{}", copy.display());
assert_eq!(std::fs::read(copy).unwrap(), b"hello");
assert_eq!(
std::fs::metadata(copy).unwrap().permissions().mode() & 0o777,
0o600
);
assert!(tool.risk(&json!({"path":"x","extra":1})).is_err());
}
}