use async_trait::async_trait;
use everruns_core::error::{AgentLoopError, Result};
use everruns_core::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
use everruns_core::traits::SessionFileSystem;
use everruns_core::typed_id::SessionId;
use std::path::Component;
use std::sync::Arc;
pub const DEFAULT_WRITE_BLOCKLIST: &[&str] = &[
".git",
"node_modules",
"target",
"dist",
"build",
".next",
".venv",
"venv",
".tox",
".gradle",
];
pub struct WriteBlocklistFileStore {
inner: Arc<dyn SessionFileSystem>,
blocklist: Vec<String>,
}
impl WriteBlocklistFileStore {
pub fn new(inner: Arc<dyn SessionFileSystem>) -> Self {
Self {
inner,
blocklist: DEFAULT_WRITE_BLOCKLIST
.iter()
.map(|s| s.to_string())
.collect(),
}
}
pub fn with_blocklist(
inner: Arc<dyn SessionFileSystem>,
blocklist: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
inner,
blocklist: blocklist.into_iter().map(Into::into).collect(),
}
}
fn check(&self, path: &str) -> Result<()> {
let p = std::path::Path::new(path);
for comp in p.components() {
if let Component::Normal(name) = comp {
let s = name.to_string_lossy();
if self.blocklist.iter().any(|b| b == s.as_ref()) {
return Err(AgentLoopError::tool(format!(
"writes into `{s}/` are blocked; write blocklist rejected `{path}`"
)));
}
}
}
Ok(())
}
}
#[async_trait]
impl SessionFileSystem for WriteBlocklistFileStore {
async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
self.inner.read_file(session_id, path).await
}
async fn write_file(
&self,
session_id: SessionId,
path: &str,
content: &str,
encoding: &str,
) -> Result<SessionFile> {
self.check(path)?;
self.inner
.write_file(session_id, path, content, encoding)
.await
}
async fn write_file_if_content_matches(
&self,
session_id: SessionId,
path: &str,
expected_content: &str,
expected_encoding: &str,
content: &str,
encoding: &str,
) -> Result<Option<SessionFile>> {
self.check(path)?;
self.inner
.write_file_if_content_matches(
session_id,
path,
expected_content,
expected_encoding,
content,
encoding,
)
.await
}
async fn delete_file(
&self,
session_id: SessionId,
path: &str,
recursive: bool,
) -> Result<bool> {
self.check(path)?;
self.inner.delete_file(session_id, path, recursive).await
}
async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
self.inner.list_directory(session_id, path).await
}
async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
self.inner.stat_file(session_id, path).await
}
async fn grep_files(
&self,
session_id: SessionId,
pattern: &str,
path_pattern: Option<&str>,
) -> Result<Vec<GrepMatch>> {
self.inner
.grep_files(session_id, pattern, path_pattern)
.await
}
async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
self.check(path)?;
self.inner.create_directory(session_id, path).await
}
async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
self.check(&file.path)?;
self.inner.seed_initial_file(session_id, file).await
}
}
#[async_trait]
pub trait FileApprovalGate: Send + Sync {
async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool;
async fn approve_delete(&self, path: &str, recursive: bool) -> bool;
}
pub struct ApprovalGatingFileStore {
inner: Arc<dyn SessionFileSystem>,
gate: Arc<dyn FileApprovalGate>,
}
impl ApprovalGatingFileStore {
pub fn new(inner: Arc<dyn SessionFileSystem>, gate: Arc<dyn FileApprovalGate>) -> Self {
Self { inner, gate }
}
async fn gated_write_with_before(
&self,
session_id: SessionId,
path: &str,
before: Option<String>,
content: &str,
encoding: &str,
) -> Result<SessionFile> {
let approved = self.gate.approve_write(path, before, content).await;
if !approved {
return Err(AgentLoopError::tool(format!(
"user denied write to `{path}`"
)));
}
self.inner
.write_file(session_id, path, content, encoding)
.await
}
}
#[async_trait]
impl SessionFileSystem for ApprovalGatingFileStore {
async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
self.inner.read_file(session_id, path).await
}
async fn write_file(
&self,
session_id: SessionId,
path: &str,
content: &str,
encoding: &str,
) -> Result<SessionFile> {
let before = self
.inner
.read_file(session_id, path)
.await?
.and_then(|f| f.content);
self.gated_write_with_before(session_id, path, before, content, encoding)
.await
}
async fn write_file_if_content_matches(
&self,
session_id: SessionId,
path: &str,
expected_content: &str,
expected_encoding: &str,
content: &str,
encoding: &str,
) -> Result<Option<SessionFile>> {
let Some(existing) = self.inner.read_file(session_id, path).await? else {
return Ok(None);
};
if existing.is_directory {
return Ok(None);
}
let current = existing.content.unwrap_or_default();
if current != expected_content || existing.encoding != expected_encoding {
return Ok(None);
}
self.gated_write_with_before(session_id, path, Some(current), content, encoding)
.await
.map(Some)
}
async fn delete_file(
&self,
session_id: SessionId,
path: &str,
recursive: bool,
) -> Result<bool> {
let approved = self.gate.approve_delete(path, recursive).await;
if !approved {
return Err(AgentLoopError::tool(format!(
"user denied delete of `{path}`"
)));
}
self.inner.delete_file(session_id, path, recursive).await
}
async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
self.inner.list_directory(session_id, path).await
}
async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
self.inner.stat_file(session_id, path).await
}
async fn grep_files(
&self,
session_id: SessionId,
pattern: &str,
path_pattern: Option<&str>,
) -> Result<Vec<GrepMatch>> {
self.inner
.grep_files(session_id, pattern, path_pattern)
.await
}
async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
self.inner.create_directory(session_id, path).await
}
async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
self.inner.seed_initial_file(session_id, file).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::in_memory::InMemorySessionFileStore;
use std::sync::Mutex;
fn sid() -> SessionId {
"session_00000000000000000000000000000001".parse().unwrap()
}
fn inner() -> Arc<dyn SessionFileSystem> {
Arc::new(InMemorySessionFileStore::new())
}
#[tokio::test]
async fn write_blocklist_rejects_blocked_paths() {
let store = WriteBlocklistFileStore::new(inner());
let err = store
.write_file(sid(), "/.git/config", "bad", "text")
.await
.expect_err("write into .git must be rejected");
assert!(format!("{err}").contains(".git"));
}
#[tokio::test]
async fn write_blocklist_allows_unblocked_paths() {
let store = WriteBlocklistFileStore::new(inner());
store
.write_file(sid(), "/src/main.rs", "fn main() {}", "text")
.await
.expect("write outside blocklist must succeed");
}
#[tokio::test]
async fn write_blocklist_reads_pass_through_blocked() {
let inner_store: Arc<dyn SessionFileSystem> = inner();
inner_store
.write_file(sid(), "/.git/config", "settings", "text")
.await
.unwrap();
let store = WriteBlocklistFileStore::new(inner_store);
let file = store
.read_file(sid(), "/.git/config")
.await
.unwrap()
.expect("read through blocklist must succeed");
assert_eq!(file.content.as_deref(), Some("settings"));
}
#[tokio::test]
async fn write_blocklist_custom_overrides_default() {
let store = WriteBlocklistFileStore::with_blocklist(inner(), ["forbidden"]);
store
.write_file(sid(), "/.git/config", "ok", "text")
.await
.expect("custom blocklist replaces default");
let err = store
.write_file(sid(), "/forbidden/x", "no", "text")
.await
.expect_err("custom blocklist entry must be enforced");
assert!(format!("{err}").contains("forbidden"));
}
struct RecordingGate {
approve: bool,
writes: Mutex<Vec<(String, Option<String>, String)>>,
deletes: Mutex<Vec<(String, bool)>>,
}
impl RecordingGate {
fn new(approve: bool) -> Self {
Self {
approve,
writes: Mutex::new(Vec::new()),
deletes: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl FileApprovalGate for RecordingGate {
async fn approve_write(&self, path: &str, before: Option<String>, after: &str) -> bool {
self.writes
.lock()
.unwrap()
.push((path.to_string(), before, after.to_string()));
self.approve
}
async fn approve_delete(&self, path: &str, recursive: bool) -> bool {
self.deletes
.lock()
.unwrap()
.push((path.to_string(), recursive));
self.approve
}
}
#[tokio::test]
async fn approval_gating_denies_write_when_user_rejects() {
let gate = Arc::new(RecordingGate::new(false));
let store = ApprovalGatingFileStore::new(inner(), gate.clone());
let err = store
.write_file(sid(), "/notes.txt", "new", "text")
.await
.expect_err("rejected write must surface as tool error");
assert!(format!("{err}").contains("denied"));
assert_eq!(gate.writes.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn approval_gating_approves_write_and_passes_before_after() {
let inner_store: Arc<dyn SessionFileSystem> = inner();
inner_store
.write_file(sid(), "/notes.txt", "original", "text")
.await
.unwrap();
let gate = Arc::new(RecordingGate::new(true));
let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
let file = store
.write_file(sid(), "/notes.txt", "updated", "text")
.await
.expect("approved write must succeed");
assert_eq!(file.content.as_deref(), Some("updated"));
let writes = gate.writes.lock().unwrap();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].0, "/notes.txt");
assert_eq!(writes[0].1.as_deref(), Some("original"));
assert_eq!(writes[0].2, "updated");
}
#[tokio::test]
async fn approval_gating_denies_delete_when_user_rejects() {
let inner_store: Arc<dyn SessionFileSystem> = inner();
inner_store
.write_file(sid(), "/scratch.txt", "x", "text")
.await
.unwrap();
let gate = Arc::new(RecordingGate::new(false));
let store = ApprovalGatingFileStore::new(inner_store, gate);
let err = store
.delete_file(sid(), "/scratch.txt", false)
.await
.expect_err("rejected delete must surface as tool error");
assert!(format!("{err}").contains("denied"));
}
#[tokio::test]
async fn approval_gating_reads_pass_through_without_prompt() {
let inner_store: Arc<dyn SessionFileSystem> = inner();
inner_store
.write_file(sid(), "/notes.txt", "hi", "text")
.await
.unwrap();
let gate = Arc::new(RecordingGate::new(false));
let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
let file = store.read_file(sid(), "/notes.txt").await.unwrap();
assert_eq!(file.unwrap().content.as_deref(), Some("hi"));
assert!(gate.writes.lock().unwrap().is_empty());
}
#[tokio::test]
async fn write_if_content_matches_takes_one_approval_per_write() {
let inner_store: Arc<dyn SessionFileSystem> = inner();
inner_store
.write_file(sid(), "/notes.txt", "original", "text")
.await
.unwrap();
let gate = Arc::new(RecordingGate::new(true));
let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
let result = store
.write_file_if_content_matches(
sid(),
"/notes.txt",
"original",
"text",
"updated",
"text",
)
.await
.unwrap();
assert!(result.is_some());
assert_eq!(gate.writes.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn write_if_content_matches_with_stale_expected_returns_none_without_prompt() {
let inner_store: Arc<dyn SessionFileSystem> = inner();
inner_store
.write_file(sid(), "/notes.txt", "actual", "text")
.await
.unwrap();
let gate = Arc::new(RecordingGate::new(true));
let store = ApprovalGatingFileStore::new(inner_store, gate.clone());
let result = store
.write_file_if_content_matches(
sid(),
"/notes.txt",
"stale-expected",
"text",
"new",
"text",
)
.await
.unwrap();
assert!(result.is_none());
assert!(gate.writes.lock().unwrap().is_empty());
}
}