use std::fmt;
const SENSITIVE_COMPONENTS: &[&str] = &[
".aws",
".azure",
".docker",
".git",
".git-credentials",
".gnupg",
".kube",
".netrc",
".npmrc",
".pypirc",
".ssh",
"credentials",
"credentials.json",
"gcloud",
];
const DEFAULT_WRITE_DENY_COMPONENTS: &[&str] = &[
"node_modules",
"target",
"dist",
"build",
".next",
".venv",
"venv",
".tox",
".gradle",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkspacePolicy {
layers: Vec<PolicyLayer>,
}
#[derive(Clone, Debug, Default)]
pub struct WorkspacePolicyBuilder {
read: Vec<String>,
write: Vec<String>,
deny_read: Vec<String>,
deny_write: Vec<String>,
deny_write_components: Vec<String>,
hidden: Vec<String>,
sensitive: Vec<String>,
recursive_delete: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkspacePolicyError {
message: String,
}
impl WorkspacePolicyError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for WorkspacePolicyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for WorkspacePolicyError {}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PolicyLayer {
read: Vec<PolicyPath>,
write: Vec<PolicyPath>,
deny_read: Vec<PolicyPath>,
deny_write: Vec<PolicyPath>,
deny_write_components: Vec<String>,
hidden: Vec<PolicyPath>,
sensitive: Vec<PolicyPath>,
recursive_delete: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PolicyPath(Vec<String>);
impl WorkspacePolicy {
pub fn read_only() -> Self {
Self {
layers: vec![PolicyLayer {
read: vec![PolicyPath::root()],
write: Vec::new(),
deny_read: Vec::new(),
deny_write: Vec::new(),
deny_write_components: Vec::new(),
hidden: vec![PolicyPath(vec![".agents".to_string()])],
sensitive: Vec::new(),
recursive_delete: false,
}],
}
}
pub fn read_write() -> Self {
Self {
layers: vec![PolicyLayer {
read: vec![PolicyPath::root()],
write: vec![PolicyPath::root()],
deny_read: Vec::new(),
deny_write: vec![PolicyPath(vec![".agents".to_string()])],
deny_write_components: DEFAULT_WRITE_DENY_COMPONENTS
.iter()
.map(|component| (*component).to_string())
.collect(),
hidden: vec![PolicyPath(vec![".agents".to_string()])],
sensitive: Vec::new(),
recursive_delete: false,
}],
}
}
pub fn builder() -> WorkspacePolicyBuilder {
WorkspacePolicyBuilder::default()
}
pub fn compose(mut self, additional: Self) -> Self {
self.layers.extend(additional.layers);
self
}
pub fn permits_read(&self, path: &str) -> bool {
PolicyPath::parse(path)
.is_ok_and(|path| self.layers.iter().all(|layer| layer.permits_read(&path)))
}
pub fn permits_write(&self, path: &str) -> bool {
PolicyPath::parse(path)
.is_ok_and(|path| self.layers.iter().all(|layer| layer.permits_write(&path)))
}
pub fn permits_read_traversal(&self, path: &str) -> bool {
PolicyPath::parse(path).is_ok_and(|path| {
self.layers
.iter()
.all(|layer| layer.permits_read_traversal(&path))
})
}
pub fn validate_path(path: &str) -> Result<(), WorkspacePolicyError> {
PolicyPath::parse(path).map(|_| ())
}
pub fn check_read(&self, path: &str) -> Result<(), WorkspacePolicyError> {
let normalized = PolicyPath::parse(path)?;
if self
.layers
.iter()
.all(|layer| layer.permits_read(&normalized))
{
Ok(())
} else {
Err(WorkspacePolicyError::new(format!(
"workspace policy denied read of `{}`",
normalized.display()
)))
}
}
pub fn check_write(&self, path: &str) -> Result<(), WorkspacePolicyError> {
let normalized = PolicyPath::parse(path)?;
if self
.layers
.iter()
.all(|layer| layer.permits_write(&normalized))
{
Ok(())
} else {
Err(WorkspacePolicyError::new(format!(
"workspace policy denied write to `{}`",
normalized.display()
)))
}
}
pub fn permits_recursive_delete(&self) -> bool {
self.layers.iter().all(|layer| layer.recursive_delete)
}
}
impl Default for WorkspacePolicy {
fn default() -> Self {
Self::read_only()
}
}
impl WorkspacePolicyBuilder {
pub fn allow_read(mut self, path: impl Into<String>) -> Self {
self.read.push(path.into());
self
}
pub fn allow_write(mut self, path: impl Into<String>) -> Self {
self.write.push(path.into());
self
}
pub fn deny_read(mut self, path: impl Into<String>) -> Self {
self.deny_read.push(path.into());
self
}
pub fn deny_write(mut self, path: impl Into<String>) -> Self {
self.deny_write.push(path.into());
self
}
pub fn deny_write_component(mut self, component: impl Into<String>) -> Self {
self.deny_write_components.push(component.into());
self
}
pub fn allow_hidden(mut self, path: impl Into<String>) -> Self {
self.hidden.push(path.into());
self
}
pub fn allow_sensitive(mut self, path: impl Into<String>) -> Self {
self.sensitive.push(path.into());
self
}
pub fn allow_recursive_delete(mut self, allow: bool) -> Self {
self.recursive_delete = allow;
self
}
pub fn build(self) -> Result<WorkspacePolicy, WorkspacePolicyError> {
Ok(WorkspacePolicy {
layers: vec![PolicyLayer {
read: parse_paths("read allow", self.read)?,
write: parse_paths("write allow", self.write)?,
deny_read: parse_paths("read deny", self.deny_read)?,
deny_write: parse_paths("write deny", self.deny_write)?,
deny_write_components: parse_components(
"write deny component",
self.deny_write_components,
)?,
hidden: parse_paths("hidden allow", self.hidden)?,
sensitive: parse_paths("sensitive allow", self.sensitive)?,
recursive_delete: self.recursive_delete,
}],
})
}
}
impl PolicyLayer {
fn permits_read(&self, path: &PolicyPath) -> bool {
self.read.iter().any(|scope| scope.contains(path))
&& !self.deny_read.iter().any(|scope| scope.contains(path))
&& self.permits_protected_components(path)
}
fn permits_read_traversal(&self, path: &PolicyPath) -> bool {
(self
.read
.iter()
.any(|scope| scope.contains(path) || path.contains(scope)))
&& !self.deny_read.iter().any(|scope| scope.contains(path))
&& self.permits_protected_traversal(path)
}
fn permits_write(&self, path: &PolicyPath) -> bool {
self.write.iter().any(|scope| scope.contains(path))
&& !self.deny_write.iter().any(|scope| scope.contains(path))
&& !path.has_any_component(&self.deny_write_components)
&& self.permits_protected_components(path)
}
fn permits_protected_components(&self, path: &PolicyPath) -> bool {
if self.sensitive.iter().any(|scope| scope.contains(path)) {
return true;
}
if path.has_sensitive_component() {
return false;
}
!path.has_hidden_component() || self.hidden.iter().any(|scope| scope.contains(path))
}
fn permits_protected_traversal(&self, path: &PolicyPath) -> bool {
if path.has_sensitive_component() {
return self
.sensitive
.iter()
.any(|scope| scope.contains(path) || path.contains(scope));
}
!path.has_hidden_component()
|| self
.hidden
.iter()
.any(|scope| scope.contains(path) || path.contains(scope))
}
}
impl PolicyPath {
fn root() -> Self {
Self(Vec::new())
}
fn parse(input: &str) -> Result<Self, WorkspacePolicyError> {
let trimmed = input.trim();
if trimmed.contains('\0') {
return Err(WorkspacePolicyError::new(format!(
"workspace path contains a NUL byte: {input:?}"
)));
}
if trimmed.contains('\\') {
return Err(WorkspacePolicyError::new(format!(
"workspace paths must use forward slashes: {input:?}"
)));
}
let canonical = crate::session_path::to_session_path(trimmed);
let without_alias = canonical.trim_start_matches('/');
let mut components = Vec::new();
for component in without_alias.split('/') {
match component {
"" | "." => {}
".." => {
return Err(WorkspacePolicyError::new(format!(
"workspace path traversal is not allowed: {input:?}"
)));
}
value => components.push(value.to_ascii_lowercase()),
}
}
Ok(Self(components))
}
fn contains(&self, candidate: &Self) -> bool {
candidate.0.starts_with(&self.0)
}
fn has_hidden_component(&self) -> bool {
self.0.iter().any(|component| component.starts_with('.'))
}
fn has_any_component(&self, denied: &[String]) -> bool {
self.0.iter().any(|component| denied.contains(component))
}
fn has_sensitive_component(&self) -> bool {
self.0.iter().any(|component| {
component == ".env"
|| component.starts_with(".env.")
|| SENSITIVE_COMPONENTS.contains(&component.as_str())
})
}
fn display(&self) -> String {
if self.0.is_empty() {
"/workspace".to_string()
} else {
format!("/workspace/{}", self.0.join("/"))
}
}
}
fn parse_paths(kind: &str, paths: Vec<String>) -> Result<Vec<PolicyPath>, WorkspacePolicyError> {
paths
.into_iter()
.map(|path| {
if path.trim().is_empty() {
return Err(WorkspacePolicyError::new(format!(
"invalid {kind} scope {path:?}: path must not be empty"
)));
}
PolicyPath::parse(&path).map_err(|error| {
WorkspacePolicyError::new(format!("invalid {kind} scope {path:?}: {error}"))
})
})
.collect()
}
fn parse_components(
kind: &str,
components: Vec<String>,
) -> Result<Vec<String>, WorkspacePolicyError> {
components
.into_iter()
.map(|component| {
let trimmed = component.trim();
if trimmed.is_empty()
|| matches!(trimmed, "." | "..")
|| trimmed.contains(['/', '\\', '\0'])
{
return Err(WorkspacePolicyError::new(format!(
"invalid {kind} {component:?}: expected one path component"
)));
}
Ok(trimmed.to_ascii_lowercase())
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_read_only_and_hides_sensitive_paths() {
let policy = WorkspacePolicy::default();
assert!(policy.permits_read("/workspace/src/lib.rs"));
assert!(!policy.permits_write("/workspace/src/lib.rs"));
assert!(!policy.permits_read("/workspace/.env"));
assert!(!policy.permits_read("/workspace/.git/config"));
assert!(policy.permits_read("/workspace/.agents/skills/example/SKILL.md"));
assert!(!policy.permits_recursive_delete());
}
#[test]
fn read_write_keeps_framework_managed_content_read_only() {
let policy = WorkspacePolicy::read_write();
assert!(policy.permits_read("/.agents/skills/example/SKILL.md"));
assert!(!policy.permits_write("/.agents/skills/example/SKILL.md"));
assert!(policy.permits_write("/generated/report.md"));
assert!(!policy.permits_write("/crates/example/target/output"));
assert!(!policy.permits_write("/web/node_modules/package/index.js"));
}
#[test]
fn custom_component_write_deny_applies_at_every_depth() {
let policy = WorkspacePolicy::builder()
.allow_write("/")
.deny_write_component("vendor")
.build()
.unwrap();
assert!(policy.permits_write("/src/generated.rs"));
assert!(!policy.permits_write("/src/vendor/generated.rs"));
assert!(!policy.permits_write("/VENDOR/generated.rs"));
}
#[test]
fn deny_wins_over_allow() {
let policy = WorkspacePolicy::builder()
.allow_read("/")
.allow_write("/workspace/output")
.deny_read("private")
.deny_write("output/locked")
.build()
.unwrap();
assert!(policy.permits_read("notes.txt"));
assert!(!policy.permits_read("private/notes.txt"));
assert!(policy.permits_write("output/result.txt"));
assert!(!policy.permits_write("output/locked/result.txt"));
}
#[test]
fn scoped_policy_allows_parent_traversal_but_not_parent_reads() {
let policy = WorkspacePolicy::builder()
.allow_read("src/generated")
.build()
.unwrap();
assert!(policy.permits_read_traversal("/workspace"));
assert!(policy.permits_read_traversal("/workspace/src"));
assert!(!policy.permits_read("/workspace/src"));
assert!(policy.permits_read("/workspace/src/generated/file.rs"));
assert!(!policy.permits_read("/workspace/tests/test.rs"));
}
#[test]
fn hidden_and_sensitive_paths_require_separate_explicit_opt_ins() {
let hidden = WorkspacePolicy::builder()
.allow_read("/")
.allow_hidden(".github")
.allow_hidden(".git")
.build()
.unwrap();
assert!(hidden.permits_read(".github/workflows/ci.yml"));
assert!(!hidden.permits_read(".git/config"));
let sensitive = WorkspacePolicy::builder()
.allow_read("/")
.allow_sensitive(".env.example")
.build()
.unwrap();
assert!(sensitive.permits_read(".env.example"));
assert!(!sensitive.permits_read(".env"));
}
#[test]
fn narrow_protected_scope_allows_only_ancestor_traversal() {
let policy = WorkspacePolicy::builder()
.allow_read(".ssh/id_ed25519")
.allow_sensitive(".ssh/id_ed25519")
.build()
.unwrap();
assert!(policy.permits_read_traversal("/.ssh"));
assert!(!policy.permits_read("/.ssh"));
assert!(policy.permits_read("/.ssh/id_ed25519"));
assert!(!policy.permits_read("/.ssh/config"));
}
#[test]
fn composition_can_only_restrict() {
let application = WorkspacePolicy::read_write();
let library = WorkspacePolicy::builder()
.allow_read("src")
.allow_write("src/generated")
.build()
.unwrap();
let policy = application.compose(library);
assert!(policy.permits_read("src/lib.rs"));
assert!(!policy.permits_read("Cargo.toml"));
assert!(policy.permits_write("src/generated/mod.rs"));
assert!(!policy.permits_write("src/lib.rs"));
}
#[test]
fn traversal_nul_and_platform_separators_fail_closed() {
let policy = WorkspacePolicy::read_write();
assert!(!policy.permits_read("src/../.env"));
assert!(!policy.permits_write("../outside"));
assert!(!policy.permits_read("src\\..\\secret"));
assert!(!policy.permits_read("bad\0path"));
}
#[test]
fn workspace_and_session_absolute_paths_share_one_namespace() {
let policy = WorkspacePolicy::builder()
.allow_read("/workspace/src")
.allow_write("/output")
.build()
.unwrap();
assert!(policy.permits_read("src/lib.rs"));
assert!(policy.permits_read("/src/lib.rs"));
assert!(policy.permits_read("/workspace/src/lib.rs"));
assert!(policy.permits_write("/workspace/output/report.md"));
}
#[test]
fn repeated_slashes_cannot_bypass_a_deny_scope() {
let policy = WorkspacePolicy::builder()
.allow_read("/")
.deny_read("private")
.build()
.unwrap();
assert!(!policy.permits_read("//workspace//private//secret.txt"));
}
#[test]
fn alternate_ascii_case_cannot_bypass_a_deny_or_sensitive_path() {
let policy = WorkspacePolicy::builder()
.allow_read("/")
.deny_read("Private")
.build()
.unwrap();
assert!(!policy.permits_read("/private/secret.txt"));
assert!(!policy.permits_read("/.ENV"));
assert!(!policy.permits_read("/.Git/config"));
}
#[test]
fn invalid_custom_scope_is_reported_at_build_time() {
let error = WorkspacePolicy::builder()
.allow_read("../outside")
.build()
.unwrap_err();
assert!(error.to_string().contains("traversal"));
let error = WorkspacePolicy::builder()
.deny_write_component("nested/vendor")
.build()
.unwrap_err();
assert!(error.to_string().contains("one path component"));
let error = WorkspacePolicy::builder()
.allow_write(" ")
.build()
.unwrap_err();
assert!(error.to_string().contains("must not be empty"));
}
}