use super::{
CapabilityManifest, Error, ExtensionPolicy, FsConnector, FsOp, FsScopes, HostCallError,
HostCallErrorCode, HostCallPayload, HostResultPayload, PolicyDecision, Result,
strip_unc_prefix,
};
use base64::Engine as _;
use serde_json::{Value, json};
use sha2::Digest as _;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
impl FsOp {
pub(super) fn parse(value: &str) -> Option<Self> {
let value = value.trim();
if value.eq_ignore_ascii_case("read") {
Some(Self::Read)
} else if value.eq_ignore_ascii_case("write") {
Some(Self::Write)
} else if value.eq_ignore_ascii_case("list") || value.eq_ignore_ascii_case("readdir") {
Some(Self::List)
} else if value.eq_ignore_ascii_case("stat") {
Some(Self::Stat)
} else if value.eq_ignore_ascii_case("mkdir") {
Some(Self::Mkdir)
} else if value.eq_ignore_ascii_case("delete")
|| value.eq_ignore_ascii_case("remove")
|| value.eq_ignore_ascii_case("rm")
{
Some(Self::Delete)
} else {
None
}
}
pub(super) const fn required_capability(self) -> &'static str {
match self {
Self::Read | Self::List | Self::Stat => "read",
Self::Write | Self::Mkdir | Self::Delete => "write",
}
}
}
impl FsScopes {
pub fn least_privilege_for_cwd(cwd: &Path) -> Result<Self> {
let root = canonicalize_root(cwd)?;
Ok(Self {
read_declared: true,
write_declared: false,
read_roots: vec![root],
write_roots: Vec::new(),
})
}
pub fn for_cwd(cwd: &Path) -> Result<Self> {
let root = canonicalize_root(cwd)?;
Ok(Self {
read_declared: true,
write_declared: true,
read_roots: vec![root.clone()],
write_roots: vec![root],
})
}
pub fn from_manifest(manifest: Option<&CapabilityManifest>, cwd: &Path) -> Result<Self> {
let Some(manifest) = manifest else {
return Self::least_privilege_for_cwd(cwd);
};
let mut read_declared = false;
let mut write_declared = false;
let mut read_roots = Vec::new();
let mut write_roots = Vec::new();
for req in &manifest.capabilities {
let cap = req.capability.trim().to_ascii_lowercase();
if cap != "read" && cap != "write" {
continue;
}
if cap == "read" {
read_declared = true;
} else {
write_declared = true;
}
let Some(scope) = &req.scope else {
continue;
};
let Some(paths) = &scope.paths else {
continue;
};
for raw in paths {
let root = resolve_scoped_root(raw, cwd)?;
if cap == "read" {
read_roots.push(root);
} else {
write_roots.push(root);
}
}
}
let fallback = canonicalize_root(cwd)?;
if read_declared && read_roots.is_empty() {
read_roots.push(fallback.clone());
}
if write_declared && write_roots.is_empty() {
write_roots.push(fallback);
}
Ok(Self {
read_declared,
write_declared,
read_roots,
write_roots,
})
}
fn roots_for_capability(&self, capability: &str) -> &[PathBuf] {
if capability.eq_ignore_ascii_case("read") {
if self.read_declared {
&self.read_roots
} else {
&[]
}
} else if self.write_declared {
&self.write_roots
} else {
&[]
}
}
}
impl FsConnector {
pub fn new(cwd: impl AsRef<Path>, policy: ExtensionPolicy, scopes: FsScopes) -> Result<Self> {
let cwd = canonicalize_root(cwd.as_ref())?;
Ok(Self {
cwd,
policy,
scopes,
})
}
pub fn handle_host_call(
&self,
call: &HostCallPayload,
extension_id: Option<&str>,
) -> HostResultPayload {
if !call.method.trim().eq_ignore_ascii_case("fs") {
return HostResultPayload {
call_id: call.call_id.clone(),
output: json!({}),
is_error: true,
error: Some(HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Unsupported hostcall method for FsConnector".to_string(),
details: Some(json!({ "method": call.method })),
retryable: None,
}),
chunk: None,
};
}
let result = self.handle_fs_params(&call.params, extension_id);
match result {
Ok(output) => HostResultPayload {
call_id: call.call_id.clone(),
output,
is_error: false,
error: None,
chunk: None,
},
Err(error) => HostResultPayload {
call_id: call.call_id.clone(),
output: json!({}),
is_error: true,
error: Some(error),
chunk: None,
},
}
}
fn handle_fs_params(
&self,
params: &Value,
extension_id: Option<&str>,
) -> std::result::Result<Value, HostCallError> {
let op = params
.get("op")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let op = FsOp::parse(op).ok_or_else(|| HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Invalid fs op".to_string(),
details: Some(json!({ "op": op })),
retryable: None,
})?;
let capability = op.required_capability();
let policy_check = self.policy.evaluate_for(capability, extension_id);
if policy_check.decision != PolicyDecision::Allow {
return Err(HostCallError {
code: HostCallErrorCode::Denied,
message: "Capability denied by policy".to_string(),
details: Some(json!({
"capability": policy_check.capability,
"decision": format!("{:?}", policy_check.decision),
"reason": policy_check.reason,
})),
retryable: None,
});
}
let roots = self.scopes.roots_for_capability(capability);
if roots.is_empty() {
return Err(HostCallError {
code: HostCallErrorCode::Denied,
message: format!("No allowed roots configured for '{capability}'"),
details: Some(json!({
"capability": capability,
"hint": "Declare capability_manifest scope.paths for this capability."
})),
retryable: None,
});
}
let path_str = params
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.ok_or_else(|| HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Missing fs path".to_string(),
details: None,
retryable: None,
})?;
let target = resolve_target_path(&self.cwd, path_str)?;
let canonical_target = match op {
FsOp::Read | FsOp::List | FsOp::Stat | FsOp::Delete => canonicalize_existing(&target),
FsOp::Write | FsOp::Mkdir => canonicalize_for_create(&target),
}?;
let matched_root = if crate::workspace::any_root_contains(roots, &canonical_target) {
roots
.iter()
.find(|root| canonical_target.starts_with(root.as_path()))
} else {
None
};
if matched_root.is_none() {
let root_hashes = roots.iter().map(|root| hash_path(root)).collect::<Vec<_>>();
tracing::warn!(
event = "ext.fs.denied",
op = ?op,
capability = capability,
path_hash = %hash_path(&canonical_target),
scope_roots = ?root_hashes,
"Denied fs operation outside allowlist",
);
return Err(HostCallError {
code: HostCallErrorCode::Denied,
message: "Path outside allowed scope; update capability_manifest scope.paths"
.to_string(),
details: Some(json!({
"capability": capability,
"path_hash": hash_path(&canonical_target),
"scope_roots": root_hashes,
"hint": "Add an allowed path to capability_manifest scope.paths."
})),
retryable: None,
});
}
let matched_root_hash = matched_root.map(|root| hash_path(root)).unwrap_or_default();
tracing::info!(
event = "ext.fs.call",
op = ?op,
capability = capability,
path_hash = %hash_path(&canonical_target),
scope_root = %matched_root_hash,
"Executing fs operation",
);
match op {
FsOp::Read => fs_op_read(params, &canonical_target),
FsOp::Write => fs_op_write(params, &canonical_target),
FsOp::List => fs_op_list(&canonical_target),
FsOp::Stat => fs_op_stat(params, &canonical_target),
FsOp::Mkdir => fs_op_mkdir(&canonical_target),
FsOp::Delete => fs_op_delete(params, &canonical_target),
}
}
}
fn resolve_target_path(cwd: &Path, raw: &str) -> std::result::Result<PathBuf, HostCallError> {
if raw.is_empty() {
return Err(HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Path is empty".to_string(),
details: None,
retryable: None,
});
}
let path = Path::new(raw);
Ok(if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
})
}
fn canonicalize_root(path: &Path) -> Result<PathBuf> {
std::fs::canonicalize(path)
.map(strip_unc_prefix)
.map_err(|err| Error::extension(format!("canonicalize: {err}")))
}
fn resolve_scoped_root(raw: &str, cwd: &Path) -> Result<PathBuf> {
let raw = raw.trim();
if raw.is_empty() {
return Err(Error::validation("Capability scope path is empty"));
}
let path = Path::new(raw);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
canonicalize_root(&resolved)
}
fn canonicalize_existing(path: &Path) -> std::result::Result<PathBuf, HostCallError> {
std::fs::canonicalize(path)
.map(strip_unc_prefix)
.map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("canonicalize: {err}"),
details: Some(json!({ "path": path.display().to_string() })),
retryable: None,
})
}
fn canonicalize_for_create(path: &Path) -> std::result::Result<PathBuf, HostCallError> {
let mut ancestor = path.to_path_buf();
while !ancestor.exists() {
ancestor = ancestor
.parent()
.ok_or_else(|| HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Path has no existing ancestor".to_string(),
details: Some(json!({ "path": path.display().to_string() })),
retryable: None,
})?
.to_path_buf();
}
let canonical_ancestor = std::fs::canonicalize(&ancestor)
.map(strip_unc_prefix)
.map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("canonicalize: {err}"),
details: Some(json!({ "path": ancestor.display().to_string() })),
retryable: None,
})?;
let suffix = path.strip_prefix(&ancestor).map_err(|_| HostCallError {
code: HostCallErrorCode::Internal,
message: "Failed to compute path suffix".to_string(),
details: Some(json!({
"path": path.display().to_string(),
"ancestor": ancestor.display().to_string(),
})),
retryable: None,
})?;
let mut normalized_parts: Vec<std::ffi::OsString> = Vec::new();
let mut up_levels: usize = 0;
for component in suffix.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::Normal(part) => normalized_parts.push(part.to_os_string()),
std::path::Component::ParentDir => {
if normalized_parts.pop().is_none() {
up_levels = up_levels.saturating_add(1);
}
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Invalid path suffix".to_string(),
details: Some(json!({
"path": path.display().to_string(),
"ancestor": ancestor.display().to_string(),
})),
retryable: None,
});
}
}
}
let mut base = canonical_ancestor;
for _ in 0..up_levels {
base = base
.parent()
.ok_or_else(|| HostCallError {
code: HostCallErrorCode::Denied,
message: "Path escapes filesystem root".to_string(),
details: Some(json!({
"path": path.display().to_string(),
"ancestor": ancestor.display().to_string(),
})),
retryable: None,
})?
.to_path_buf();
}
let mut normalized_suffix = PathBuf::new();
for part in normalized_parts {
normalized_suffix.push(part);
}
Ok(base.join(normalized_suffix))
}
fn hash_path(path: &Path) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
let digest = hasher.finalize();
crate::package_manager::hex_encode(&digest)
}
fn fs_op_read(params: &Value, path: &Path) -> std::result::Result<Value, HostCallError> {
let encoding = params
.get("encoding")
.and_then(Value::as_str)
.map_or("utf8", str::trim);
if let Ok(meta) = fs::metadata(path) {
if !meta.is_file() {
return Err(HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: format!("Path {} is not a regular file", path.display()),
details: None,
retryable: None,
});
}
if meta.len() > crate::tools::READ_TOOL_MAX_BYTES {
return Err(HostCallError {
code: HostCallErrorCode::Io,
message: format!(
"File is too large ({} bytes). Max allowed is {} bytes.",
meta.len(),
crate::tools::READ_TOOL_MAX_BYTES
),
details: None,
retryable: None,
});
}
}
let file = std::fs::File::open(path).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("read: {err}"),
details: None,
retryable: None,
})?;
let mut bytes = Vec::new();
file.take(crate::tools::READ_TOOL_MAX_BYTES.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("read: {err}"),
details: None,
retryable: None,
})?;
if bytes.len() as u64 > crate::tools::READ_TOOL_MAX_BYTES {
return Err(HostCallError {
code: HostCallErrorCode::Io,
message: format!(
"File is too large (exceeds max allowed {} bytes).",
crate::tools::READ_TOOL_MAX_BYTES
),
details: None,
retryable: None,
});
}
match encoding.to_ascii_lowercase().as_str() {
"utf8" | "utf-8" => {
let text = String::from_utf8(bytes).map_err(|_| HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "File is not valid UTF-8; use base64 encoding".to_string(),
details: Some(json!({ "encoding": "base64" })),
retryable: None,
})?;
Ok(json!({ "encoding": "utf8", "text": text }))
}
"base64" => {
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
Ok(json!({ "encoding": "base64", "data": data }))
}
other => Err(HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Invalid encoding".to_string(),
details: Some(json!({ "encoding": other })),
retryable: None,
}),
}
}
fn fs_op_write(params: &Value, path: &Path) -> std::result::Result<Value, HostCallError> {
let encoding = params
.get("encoding")
.and_then(Value::as_str)
.map_or("utf8", str::trim);
let data = params
.get("data")
.and_then(Value::as_str)
.ok_or_else(|| HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Missing write data".to_string(),
details: None,
retryable: None,
})?;
let bytes = match encoding.to_ascii_lowercase().as_str() {
"utf8" | "utf-8" => data.as_bytes().to_vec(),
"base64" => base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|err| HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: format!("Invalid base64: {err}"),
details: None,
retryable: None,
})?,
other => {
return Err(HostCallError {
code: HostCallErrorCode::InvalidRequest,
message: "Invalid encoding".to_string(),
details: Some(json!({ "encoding": other })),
retryable: None,
});
}
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("mkdir parent: {err}"),
details: None,
retryable: None,
})?;
}
fs::write(path, &bytes).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("write: {err}"),
details: None,
retryable: None,
})?;
Ok(json!({ "bytes_written": bytes.len() }))
}
fn fs_op_list(path: &Path) -> std::result::Result<Value, HostCallError> {
let read_dir = fs::read_dir(path).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("read_dir: {err}"),
details: None,
retryable: None,
})?;
let mut entries = Vec::new();
for entry in read_dir {
if entries.len() >= crate::tools::LS_SCAN_HARD_LIMIT {
return Err(HostCallError {
code: HostCallErrorCode::Io,
message: format!(
"Directory scan limit reached ({} entries).",
crate::tools::LS_SCAN_HARD_LIMIT
),
details: None,
retryable: None,
});
}
let entry = entry.map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("read_dir entry: {err}"),
details: None,
retryable: None,
})?;
let name = entry.file_name().to_string_lossy().to_string();
let meta = fs::symlink_metadata(entry.path()).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("metadata: {err}"),
details: None,
retryable: None,
})?;
let kind = if meta.file_type().is_symlink() {
"symlink"
} else if meta.is_dir() {
"dir"
} else if meta.is_file() {
"file"
} else {
"other"
};
entries.push(json!({ "name": name, "kind": kind }));
}
Ok(json!({ "entries": entries }))
}
fn fs_op_stat(params: &Value, path: &Path) -> std::result::Result<Value, HostCallError> {
let follow = params
.get("follow_symlinks")
.and_then(Value::as_bool)
.unwrap_or(true);
let meta = if follow {
fs::metadata(path)
} else {
fs::symlink_metadata(path)
}
.map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("stat: {err}"),
details: None,
retryable: None,
})?;
Ok(json!({
"is_file": meta.is_file(),
"is_dir": meta.is_dir(),
"len": meta.len(),
}))
}
fn fs_op_mkdir(path: &Path) -> std::result::Result<Value, HostCallError> {
fs::create_dir_all(path).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("mkdir: {err}"),
details: None,
retryable: None,
})?;
Ok(json!({ "created": true }))
}
fn fs_op_delete(params: &Value, path: &Path) -> std::result::Result<Value, HostCallError> {
let recursive = params
.get("recursive")
.and_then(Value::as_bool)
.unwrap_or(false);
let meta = fs::symlink_metadata(path).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("stat: {err}"),
details: None,
retryable: None,
})?;
if meta.is_dir() && !meta.file_type().is_symlink() {
if recursive {
fs::remove_dir_all(path)
} else {
fs::remove_dir(path)
}
.map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("remove_dir: {err}"),
details: None,
retryable: None,
})?;
return Ok(json!({ "deleted": true, "kind": "dir" }));
}
fs::remove_file(path).map_err(|err| HostCallError {
code: HostCallErrorCode::Io,
message: format!("remove_file: {err}"),
details: None,
retryable: None,
})?;
Ok(json!({ "deleted": true, "kind": "file" }))
}