303 lines
9.2 KiB
Rust
303 lines
9.2 KiB
Rust
use crate::access::AccessMode;
|
|
use anyhow::{bail, Context, Result};
|
|
use std::ffi::OsString;
|
|
use std::fs;
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SecurityContext {
|
|
pub mode: AccessMode,
|
|
pub cwd: PathBuf,
|
|
pub read_roots: Vec<PathBuf>,
|
|
pub blocked_write_roots: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum ToolAction {
|
|
List { path: PathBuf },
|
|
Read { path: PathBuf },
|
|
Search { path: PathBuf },
|
|
Write { path: PathBuf },
|
|
Edit { path: PathBuf },
|
|
Shell { command: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PolicyDecision {
|
|
Allow,
|
|
Ask { reason: String },
|
|
Deny { reason: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ToolAvailability {
|
|
Unavailable,
|
|
Available,
|
|
RequiresApproval,
|
|
}
|
|
|
|
pub struct SecurityPolicy;
|
|
|
|
impl SecurityPolicy {
|
|
pub fn tool_availability(ctx: &SecurityContext, tool: &str) -> ToolAvailability {
|
|
match tool {
|
|
"ls" | "read" | "grep" => ToolAvailability::Available,
|
|
"write" | "edit" => match ctx.mode {
|
|
AccessMode::ReadOnly => ToolAvailability::Unavailable,
|
|
AccessMode::WorkspaceEdit | AccessMode::FullAccess => ToolAvailability::Available,
|
|
},
|
|
"shell" => match ctx.mode {
|
|
AccessMode::ReadOnly => ToolAvailability::Unavailable,
|
|
AccessMode::WorkspaceEdit => ToolAvailability::RequiresApproval,
|
|
AccessMode::FullAccess => ToolAvailability::Available,
|
|
},
|
|
_ => ToolAvailability::Unavailable,
|
|
}
|
|
}
|
|
|
|
pub fn check(ctx: &SecurityContext, action: &ToolAction) -> PolicyDecision {
|
|
match action {
|
|
ToolAction::List { path } | ToolAction::Read { path } | ToolAction::Search { path } => {
|
|
check_read(ctx, path)
|
|
}
|
|
ToolAction::Write { path } | ToolAction::Edit { path } => check_write(ctx, path),
|
|
ToolAction::Shell { command } => match ctx.mode {
|
|
AccessMode::ReadOnly => PolicyDecision::Deny {
|
|
reason: "shell is unavailable in read-only mode".into(),
|
|
},
|
|
AccessMode::WorkspaceEdit => PolicyDecision::Ask {
|
|
reason: format!(
|
|
"shell commands require user approval in workspace-edit mode: {command}"
|
|
),
|
|
},
|
|
AccessMode::FullAccess => PolicyDecision::Allow,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_read(ctx: &SecurityContext, path: &Path) -> PolicyDecision {
|
|
match ctx.mode {
|
|
AccessMode::ReadOnly | AccessMode::WorkspaceEdit => match canonicalize_existing(path) {
|
|
Ok(path) if is_under_any_root(&path, &canonical_roots(&ctx.read_roots)) => {
|
|
PolicyDecision::Allow
|
|
}
|
|
Ok(path) => PolicyDecision::Deny {
|
|
reason: format!(
|
|
"path escapes read-only roots: {} (allowed roots: {})",
|
|
path.display(),
|
|
roots_display(&ctx.read_roots)
|
|
),
|
|
},
|
|
Err(err) => PolicyDecision::Deny {
|
|
reason: err.to_string(),
|
|
},
|
|
},
|
|
AccessMode::FullAccess => PolicyDecision::Allow,
|
|
}
|
|
}
|
|
|
|
fn check_write(ctx: &SecurityContext, path: &Path) -> PolicyDecision {
|
|
if matches!(ctx.mode, AccessMode::ReadOnly) {
|
|
return PolicyDecision::Deny {
|
|
reason: "write/edit tools are unavailable in read-only mode".into(),
|
|
};
|
|
}
|
|
|
|
let canonical = match canonicalize_for_create_or_write(path) {
|
|
Ok(path) => path,
|
|
Err(err) => {
|
|
return PolicyDecision::Deny {
|
|
reason: err.to_string(),
|
|
}
|
|
}
|
|
};
|
|
|
|
let blocked = canonical_roots(&ctx.blocked_write_roots);
|
|
if is_under_any_root(&canonical, &blocked)
|
|
|| is_under_any_root(&normalize_lexical(path), &ctx.blocked_write_roots)
|
|
{
|
|
return PolicyDecision::Deny {
|
|
reason: format!(
|
|
"writes are blocked under read-only docs directory: {}",
|
|
canonical.display()
|
|
),
|
|
};
|
|
}
|
|
|
|
if matches!(ctx.mode, AccessMode::WorkspaceEdit) {
|
|
let workspace = canonical_roots(std::slice::from_ref(&ctx.cwd));
|
|
if !is_under_any_root(&canonical, &workspace) {
|
|
return PolicyDecision::Deny {
|
|
reason: format!(
|
|
"write path escapes workspace-edit root: {} (workspace root: {})",
|
|
canonical.display(),
|
|
ctx.cwd.display()
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
PolicyDecision::Allow
|
|
}
|
|
|
|
pub fn canonicalize_existing(path: &Path) -> Result<PathBuf> {
|
|
fs::canonicalize(path).with_context(|| format!("resolving {}", path.display()))
|
|
}
|
|
|
|
pub fn canonicalize_for_create_or_write(path: &Path) -> Result<PathBuf> {
|
|
if path.exists() {
|
|
return canonicalize_existing(path);
|
|
}
|
|
|
|
let mut missing = Vec::<OsString>::new();
|
|
let mut ancestor = path;
|
|
loop {
|
|
if ancestor.exists() {
|
|
let mut out = fs::canonicalize(ancestor)
|
|
.with_context(|| format!("resolving {}", ancestor.display()))?;
|
|
for component in missing.iter().rev() {
|
|
out.push(component);
|
|
}
|
|
return Ok(normalize_lexical(&out));
|
|
}
|
|
|
|
let Some(name) = ancestor.file_name() else {
|
|
bail!("no existing ancestor for {}", path.display());
|
|
};
|
|
missing.push(name.to_os_string());
|
|
ancestor = ancestor
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("no existing ancestor for {}", path.display()))?;
|
|
}
|
|
}
|
|
|
|
pub fn normalize_lexical(path: &Path) -> PathBuf {
|
|
let mut out = PathBuf::new();
|
|
for c in path.components() {
|
|
match c {
|
|
Component::CurDir => {}
|
|
Component::ParentDir => {
|
|
out.pop();
|
|
}
|
|
other => out.push(other.as_os_str()),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn is_under_root(path: &Path, root: &Path) -> bool {
|
|
path == root || path.starts_with(root)
|
|
}
|
|
|
|
pub fn is_under_any_root(path: &Path, roots: &[PathBuf]) -> bool {
|
|
roots.iter().any(|root| is_under_root(path, root))
|
|
}
|
|
|
|
fn canonical_roots(roots: &[PathBuf]) -> Vec<PathBuf> {
|
|
roots
|
|
.iter()
|
|
.filter_map(|root| fs::canonicalize(root).ok())
|
|
.collect()
|
|
}
|
|
|
|
fn roots_display(roots: &[PathBuf]) -> String {
|
|
if roots.is_empty() {
|
|
return "<none>".into();
|
|
}
|
|
roots
|
|
.iter()
|
|
.map(|root| root.display().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
fn ctx(root: &Path, docs: &Path, mode: AccessMode) -> SecurityContext {
|
|
SecurityContext {
|
|
mode,
|
|
cwd: root.to_path_buf(),
|
|
read_roots: vec![root.to_path_buf(), docs.to_path_buf()],
|
|
blocked_write_roots: vec![docs.to_path_buf()],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_edit_asks_for_shell_and_allows_workspace_writes() {
|
|
let root = tempdir().unwrap();
|
|
let docs = tempdir().unwrap();
|
|
let ctx = ctx(root.path(), docs.path(), AccessMode::WorkspaceEdit);
|
|
assert_eq!(
|
|
SecurityPolicy::tool_availability(&ctx, "shell"),
|
|
ToolAvailability::RequiresApproval
|
|
);
|
|
assert!(matches!(
|
|
SecurityPolicy::check(
|
|
&ctx,
|
|
&ToolAction::Shell {
|
|
command: "pwd".into()
|
|
}
|
|
),
|
|
PolicyDecision::Ask { .. }
|
|
));
|
|
assert_eq!(
|
|
SecurityPolicy::check(
|
|
&ctx,
|
|
&ToolAction::Write {
|
|
path: root.path().join("x")
|
|
}
|
|
),
|
|
PolicyDecision::Allow
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_edit_denies_writes_outside_workspace_and_under_docs() {
|
|
let root = tempdir().unwrap();
|
|
let docs = tempdir().unwrap();
|
|
let outside = tempdir().unwrap();
|
|
let ctx = ctx(root.path(), docs.path(), AccessMode::WorkspaceEdit);
|
|
assert!(matches!(
|
|
SecurityPolicy::check(
|
|
&ctx,
|
|
&ToolAction::Write {
|
|
path: outside.path().join("x")
|
|
}
|
|
),
|
|
PolicyDecision::Deny { .. }
|
|
));
|
|
assert!(matches!(
|
|
SecurityPolicy::check(
|
|
&ctx,
|
|
&ToolAction::Write {
|
|
path: docs.path().join("x")
|
|
}
|
|
),
|
|
PolicyDecision::Deny { .. }
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn workspace_edit_denies_symlink_escape_writes() {
|
|
let root = tempdir().unwrap();
|
|
let docs = tempdir().unwrap();
|
|
let outside = tempdir().unwrap();
|
|
std::os::unix::fs::symlink(outside.path(), root.path().join("outside_link")).unwrap();
|
|
let ctx = ctx(root.path(), docs.path(), AccessMode::WorkspaceEdit);
|
|
assert!(matches!(
|
|
SecurityPolicy::check(
|
|
&ctx,
|
|
&ToolAction::Write {
|
|
path: root.path().join("outside_link/new")
|
|
}
|
|
),
|
|
PolicyDecision::Deny { .. }
|
|
));
|
|
}
|
|
}
|