use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum FsAccessMode {
ReadOnly,
#[default]
WorkspaceWrite,
DangerFullAccess,
}
impl FsAccessMode {
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
}
impl std::str::FromStr for FsAccessMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"read-only" | "readonly" | "ro" => Ok(Self::ReadOnly),
"workspace-write" | "workspace" | "ws" => Ok(Self::WorkspaceWrite),
"danger-full-access" | "full-access" | "danger" => Ok(Self::DangerFullAccess),
other => Err(format!(
"unknown fs access mode: {other}. Expected one of: read-only, workspace-write, danger-full-access"
)),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FsAccessPolicy {
pub mode: FsAccessMode,
pub workspace: Option<PathBuf>,
}
impl FsAccessPolicy {
pub fn danger_full_access() -> Self {
Self {
mode: FsAccessMode::DangerFullAccess,
workspace: None,
}
}
pub fn workspace_write(workspace: PathBuf) -> Self {
Self {
mode: FsAccessMode::WorkspaceWrite,
workspace: Some(workspace),
}
}
pub fn check_write(&self, target: &Path) -> Result<(), FsAccessError> {
check_write(target, self.workspace.as_deref(), self.mode)
}
}
pub async fn authorize_write(
ctx: &crate::tool::ToolCtx,
target: &Path,
operation: &str,
allow_permit: bool,
) -> Result<bool, crate::error::RuntimeError> {
let Err(error) = ctx.fs_access.check_write(target) else {
return Ok(false);
};
let blocked = |detail: &str| {
Err(crate::error::RuntimeError::ToolFailed(format!(
"{operation}({}): {error}{detail}",
target.display()
)))
};
if !allow_permit {
return blocked("");
}
let Some(permit) = ctx.invocation_authorization() else {
return blocked(" — no central authorization for this call");
};
if !permit.covers(operation, target) {
return blocked(" — central authorization does not cover this target");
}
Ok(true)
}
#[derive(Debug, Error)]
pub enum FsAccessError {
#[error("read-only fs access: refusing to write {}", path.display())]
ReadOnlyBlocked { path: PathBuf },
#[error(
"workspace-write fs access: refusing to write {} outside workspace root {}",
path.display(),
workspace.display()
)]
OutsideWorkspace { path: PathBuf, workspace: PathBuf },
}
pub fn check_write(
target: &Path,
workspace: Option<&Path>,
mode: FsAccessMode,
) -> Result<(), FsAccessError> {
match mode {
FsAccessMode::DangerFullAccess => Ok(()),
FsAccessMode::ReadOnly => Err(FsAccessError::ReadOnlyBlocked {
path: target.to_path_buf(),
}),
FsAccessMode::WorkspaceWrite => {
let canonical = canonicalize_stable(target);
if is_temp_path(&canonical) {
return Ok(());
}
let ws = match workspace {
Some(ws) => canonicalize_stable(ws),
None => {
return Err(FsAccessError::OutsideWorkspace {
path: canonical,
workspace: PathBuf::from("<none>"),
});
}
};
if canonical.starts_with(&ws) {
Ok(())
} else {
Err(FsAccessError::OutsideWorkspace {
path: canonical,
workspace: ws,
})
}
}
}
}
pub(crate) fn is_temp_path(path: &Path) -> bool {
let canonical = canonicalize_stable(path);
if canonical.starts_with(canonicalize_stable(&std::env::temp_dir())) {
return true;
}
#[cfg(unix)]
if canonical.starts_with(canonicalize_stable(Path::new("/tmp"))) {
return true;
}
false
}
pub(crate) fn canonicalize_stable(p: &Path) -> PathBuf {
let absolute = if p.is_absolute() {
p.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(p),
Err(_) => return p.to_path_buf(),
}
};
let mut ancestor = absolute.as_path();
let mut suffix: Vec<&std::ffi::OsStr> = Vec::new();
let root = loop {
if let Ok(real) = ancestor.canonicalize() {
break real;
}
match (ancestor.parent(), ancestor.file_name()) {
(Some(parent), Some(name)) => {
suffix.push(name);
ancestor = parent;
}
_ => return absolute,
}
};
let mut out = root;
for name in suffix.into_iter().rev() {
out.push(name);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn authorized_ctx(tool: &str, target: &Path) -> crate::tool::ToolCtx {
let permit = crate::permission::InvocationAuthorization::new(
crate::permission::PermissionRequestId::now(),
"call-1",
tool,
crate::permission::ResourceProvenance::none()
.with_path(&crate::tool::ToolCtx::default(), target)
.unwrap(),
crate::permission::ExecutionBoundary::Sandboxed,
);
crate::tool::ToolCtx::default()
.with_fs_access(FsAccessPolicy {
mode: FsAccessMode::ReadOnly,
workspace: None,
})
.authorized_for(permit)
}
#[tokio::test]
async fn central_permit_allows_exact_target_without_pending_approval() {
let target = PathBuf::from("/etc/atman-permit-test");
let approval = std::sync::Arc::new(crate::session::ApprovalRegistry::new());
let mut ctx = authorized_ctx("fs.write", &target).with_approval(approval.clone());
ctx.flow_run_id = Some(crate::event::FlowRunId::now());
assert!(
authorize_write(&ctx, &target, "fs.write", true)
.await
.unwrap()
);
assert!(approval.list_pending().is_empty());
}
#[tokio::test]
async fn central_permit_rejects_tool_and_target_mismatches() {
let target = PathBuf::from("/etc/atman-permit-test");
let ctx = authorized_ctx("fs.write", &target);
assert!(
authorize_write(&ctx, &target, "fs.edit", true)
.await
.is_err()
);
assert!(
authorize_write(&ctx, Path::new("/etc/atman-other-target"), "fs.write", true,)
.await
.is_err()
);
}
#[tokio::test]
async fn per_call_contexts_do_not_share_permits() {
let first = PathBuf::from("/etc/atman-first");
let second = PathBuf::from("/etc/atman-second");
let first_ctx = authorized_ctx("fs.write", &first);
let second_ctx = authorized_ctx("fs.write", &second);
assert!(
authorize_write(&first_ctx, &first, "fs.write", true)
.await
.is_ok()
);
assert!(
authorize_write(&first_ctx, &second, "fs.write", true)
.await
.is_err()
);
assert!(
authorize_write(&second_ctx, &second, "fs.write", true)
.await
.is_ok()
);
assert!(
authorize_write(&second_ctx, &first, "fs.write", true)
.await
.is_err()
);
}
#[test]
fn parse_canonical_forms() {
use std::str::FromStr;
assert_eq!(
FsAccessMode::from_str("read-only").unwrap(),
FsAccessMode::ReadOnly
);
assert_eq!(
FsAccessMode::from_str("workspace-write").unwrap(),
FsAccessMode::WorkspaceWrite
);
assert_eq!(
FsAccessMode::from_str("danger-full-access").unwrap(),
FsAccessMode::DangerFullAccess
);
}
#[test]
fn parse_aliases() {
use std::str::FromStr;
assert_eq!(
FsAccessMode::from_str("ro").unwrap(),
FsAccessMode::ReadOnly
);
assert_eq!(
FsAccessMode::from_str("ws").unwrap(),
FsAccessMode::WorkspaceWrite
);
assert_eq!(
FsAccessMode::from_str("danger").unwrap(),
FsAccessMode::DangerFullAccess
);
}
#[test]
fn parse_rejects_unknown() {
use std::str::FromStr;
let err = FsAccessMode::from_str("chaos").unwrap_err();
assert!(err.contains("chaos"));
}
#[test]
fn default_is_workspace_write() {
assert_eq!(FsAccessMode::default(), FsAccessMode::WorkspaceWrite);
}
#[test]
fn round_trip_as_str_and_from_str() {
use std::str::FromStr;
for mode in [
FsAccessMode::ReadOnly,
FsAccessMode::WorkspaceWrite,
FsAccessMode::DangerFullAccess,
] {
assert_eq!(FsAccessMode::from_str(mode.as_str()).unwrap(), mode);
}
}
#[test]
fn read_only_blocks_every_write() {
let ws = TempDir::new().unwrap();
let target = ws.path().join("inside.txt");
let err = check_write(&target, Some(ws.path()), FsAccessMode::ReadOnly).unwrap_err();
assert!(matches!(err, FsAccessError::ReadOnlyBlocked { .. }));
}
#[test]
fn danger_full_access_permits_arbitrary_paths() {
let target = std::env::temp_dir().join("some/absurd/deep/path.txt");
assert!(check_write(&target, None, FsAccessMode::DangerFullAccess).is_ok());
assert!(
check_write(
Path::new("/etc/passwd"),
None,
FsAccessMode::DangerFullAccess
)
.is_ok()
);
}
#[test]
fn workspace_write_allows_paths_inside_workspace() {
let ws = TempDir::new().unwrap();
let nested = ws.path().join("sub/dir");
std::fs::create_dir_all(&nested).unwrap();
let target = nested.join("a.txt");
assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
}
#[test]
fn workspace_write_allows_paths_inside_tempdir() {
let ws = TempDir::new().unwrap();
let scratch = TempDir::new().unwrap();
let target = scratch.path().join("scratch.txt");
assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
}
#[cfg(unix)]
#[test]
fn workspace_write_allows_platform_tmp_root() {
let ws = TempDir::new().unwrap();
let target = Path::new("/tmp").join("atman-scratch.txt");
assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
}
#[test]
fn workspace_write_blocks_paths_outside_workspace_and_tempdir() {
let ws = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_target = outside.path().join("../../etc/passwd");
let bad = PathBuf::from("/etc/passwd");
let err = check_write(&bad, Some(ws.path()), FsAccessMode::WorkspaceWrite).unwrap_err();
assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
drop(outside_target);
}
#[test]
fn workspace_write_without_workspace_only_allows_tempdir() {
let scratch = TempDir::new().unwrap();
assert!(
check_write(
&scratch.path().join("f.txt"),
None,
FsAccessMode::WorkspaceWrite,
)
.is_ok()
);
let err =
check_write(Path::new("/etc/passwd"), None, FsAccessMode::WorkspaceWrite).unwrap_err();
assert!(matches!(err, FsAccessError::OutsideWorkspace { .. }));
}
#[test]
fn nonexistent_target_still_checked_against_workspace() {
let ws = TempDir::new().unwrap();
let target = ws.path().join("does/not/exist/yet.txt");
assert!(check_write(&target, Some(ws.path()), FsAccessMode::WorkspaceWrite).is_ok());
}
#[test]
fn error_messages_reference_the_offending_paths() {
let ws = TempDir::new().unwrap();
let err = check_write(
Path::new("/etc/passwd"),
Some(ws.path()),
FsAccessMode::WorkspaceWrite,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("/etc/passwd"));
assert!(msg.contains(&ws.path().display().to_string()));
}
}