diff --git a/Cargo.toml b/Cargo.toml index 21c7519..c559a5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ exclude = [ ] [dependencies] + +[dev-dependencies] +proptest = "1" diff --git a/src/decision.rs b/src/decision.rs new file mode 100644 index 0000000..d61a72b --- /dev/null +++ b/src/decision.rs @@ -0,0 +1,147 @@ +//! Permission decisions returned to the AI agent. +//! +//! Every tool request is classified into one of three permissions: the agent +//! may proceed ([`Decision::Allow`]), must ask the user for confirmation +//! ([`Decision::Ask`]), or is blocked outright ([`Decision::Deny`]). +//! +//! [`DecisionResult`] bundles the permission with a human-readable reason, +//! the pipeline layer that produced it, and the tool name. + +/// Permission granted to the agent for a single tool invocation. +/// +/// Ordered by strictness: `Allow < Ask < Deny`. When multiple rules match, +/// the most restrictive permission wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Decision { + /// The agent may proceed without user intervention. + Allow, + /// The agent must obtain explicit user approval before proceeding. + Ask, + /// The operation is blocked – the agent may not proceed. + Deny, +} + +impl Decision { + /// Numeric strictness level used for comparisons. + /// `Allow (0) < Ask (1) < Deny (2)`. + pub fn strictness(self) -> u8 { + match self { + Self::Allow => 0, + Self::Ask => 1, + Self::Deny => 2, + } + } + + /// Return whichever of `self` and `other` is more restrictive. + pub fn most_restrictive(self, other: Self) -> Self { + if other.strictness() > self.strictness() { + other + } else { + self + } + } +} + +impl std::fmt::Display for Decision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Allow => write!(f, "allow"), + Self::Ask => write!(f, "ask"), + Self::Deny => write!(f, "deny"), + } + } +} + +/// A [`Decision`] together with the context that produced it. +#[derive(Debug, Clone)] +pub struct DecisionResult { + /// The permission granted. + pub decision: Decision, + /// Human-readable explanation of why this decision was reached. + pub reason: String, + /// Pipeline layer that produced the decision (e.g. ‘hard_deny’, ‘sensitive’). + pub layer: &'static str, + /// Name of the tool being classified (e.g. ‘Bash’, ‘Write’). + pub tool_name: String, +} + +impl DecisionResult { + pub fn new(decision: Decision, reason: impl Into, layer: &'static str) -> Self { + Self { + decision, + reason: reason.into(), + layer, + tool_name: String::new(), + } + } + + pub fn with_tool(mut self, tool: impl Into) -> Self { + self.tool_name = tool.into(); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Decision ──────────────────────────────────────────────────────── + + #[test] + fn strictness_ordering() { + assert!(Decision::Allow.strictness() < Decision::Ask.strictness()); + assert!(Decision::Ask.strictness() < Decision::Deny.strictness()); + } + + #[test] + fn most_restrictive_picks_stricter() { + assert_eq!( + Decision::Allow.most_restrictive(Decision::Ask), + Decision::Ask + ); + assert_eq!( + Decision::Ask.most_restrictive(Decision::Allow), + Decision::Ask + ); + assert_eq!( + Decision::Ask.most_restrictive(Decision::Deny), + Decision::Deny + ); + assert_eq!( + Decision::Deny.most_restrictive(Decision::Allow), + Decision::Deny + ); + } + + #[test] + fn most_restrictive_is_idempotent() { + for d in [Decision::Allow, Decision::Ask, Decision::Deny] { + assert_eq!(d.most_restrictive(d), d); + } + } + + #[test] + fn display_lowercase() { + assert_eq!(Decision::Allow.to_string(), "allow"); + assert_eq!(Decision::Ask.to_string(), "ask"); + assert_eq!(Decision::Deny.to_string(), "deny"); + } + + // ── DecisionResult ────────────────────────────────────────────────── + + #[test] + fn new_sets_fields() { + let r = DecisionResult::new(Decision::Deny, "bad command", "hard_deny"); + assert_eq!(r.decision, Decision::Deny); + assert_eq!(r.reason, "bad command"); + assert_eq!(r.layer, "hard_deny"); + assert!(r.tool_name.is_empty()); + } + + #[test] + fn with_tool_chains() { + let r = DecisionResult::new(Decision::Allow, "", "default").with_tool("Bash"); + assert_eq!(r.tool_name, "Bash"); + assert_eq!(r.decision, Decision::Allow); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..92bad99 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +/// Holdit — a safety harness for AI coding agents. +pub mod decision; +pub mod path_util; diff --git a/src/path_util.rs b/src/path_util.rs new file mode 100644 index 0000000..ff93caf --- /dev/null +++ b/src/path_util.rs @@ -0,0 +1,277 @@ +/// Path expansion, resolution, and canonicalisation. +use std::path::{Component, Path, PathBuf}; + +/// Expand `~` to the user's home directory. +/// +/// On Unix this reads `$HOME`; on Windows it reads `%USERPROFILE%`. +/// Returns the input unchanged if the home directory cannot be determined. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home_dir() { + return home.join(rest); + } + } else if path == "~" + && let Some(home) = home_dir() + { + return home; + } + PathBuf::from(path) +} + +/// Resolve a path: expand tilde, make absolute, canonicalise if possible. +/// Falls back to lexical resolution if the path doesn't exist on disk. +pub fn resolve_path(raw: &str) -> PathBuf { + let expanded = expand_tilde(raw); + // Try canonical (resolves symlinks, requires path to exist) + if let Ok(canonical) = expanded.canonicalize() { + return canonical; + } + // Fallback: make absolute via cwd + if expanded.is_absolute() { + lexical_clean(&expanded) + } else if let Ok(cwd) = std::env::current_dir() { + lexical_clean(&cwd.join(&expanded)) + } else { + expanded + } +} + +/// Check if `child` is contained within `parent` (lexical check on resolved paths). +/// +/// Returns `true` when `child` is identical to `parent` or is a descendant of it. +/// This is a component-level check, so `/a/bc` is **not** within `/a/b`. +pub fn is_within(child: &Path, parent: &Path) -> bool { + child == parent || child.starts_with(parent) +} + +/// Return a user-friendly display path, replacing the home directory with `~`. +pub fn friendly_path(resolved: &Path) -> String { + if let Some(home) = home_dir() { + if resolved == home { + return "~".into(); + } + if let Ok(stripped) = resolved.strip_prefix(&home) { + return format!("~/{}", stripped.display()); + } + } + resolved.display().to_string() +} + +/// Get the user's home directory from environment variables. +/// +/// Uses `$HOME` on Unix and `%USERPROFILE%` on Windows. +pub fn home_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("HOME").map(PathBuf::from) + } + #[cfg(windows)] + { + std::env::var_os("USERPROFILE").map(PathBuf::from) + } +} + +/// Lexically clean a path (resolve `.` and `..` without filesystem access). +fn lexical_clean(path: &Path) -> PathBuf { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if let Some(Component::Normal(_)) = components.last().copied() { + components.pop(); + } else { + components.push(component); + } + } + _ => components.push(component), + } + } + components.iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + // ── expand_tilde ──────────────────────────────────────────────────── + + #[test] + fn expand_tilde_substitutes_home() { + let home = home_dir().unwrap(); + let result = expand_tilde("~/foo/bar"); + assert_eq!(result, home.join("foo/bar")); + } + + #[test] + fn expand_tilde_bare_returns_home() { + let home = home_dir().unwrap(); + assert_eq!(expand_tilde("~"), home); + } + + #[test] + fn expand_tilde_ignores_absolute() { + assert_eq!(expand_tilde("/usr/bin"), PathBuf::from("/usr/bin")); + } + + #[test] + fn expand_tilde_ignores_relative() { + assert_eq!(expand_tilde("foo/bar"), PathBuf::from("foo/bar")); + } + + #[test] + fn expand_tilde_ignores_tilde_mid_path() { + assert_eq!(expand_tilde("/home/~/oops"), PathBuf::from("/home/~/oops")); + } + + // ── resolve_path ──────────────────────────────────────────────────── + + #[test] + fn resolve_path_expands_tilde() { + let home = home_dir().unwrap(); + let result = resolve_path("~/some/nonexistent/path"); + assert!(result.starts_with(&home)); + assert!(result.ends_with("some/nonexistent/path")); + } + + #[test] + fn resolve_path_cleans_dot_segments() { + let result = resolve_path("/a/b/../c/./d"); + assert_eq!(result, PathBuf::from("/a/c/d")); + } + + #[test] + fn resolve_path_makes_relative_absolute() { + let result = resolve_path("relative/path"); + assert!(result.is_absolute()); + assert!(result.ends_with("relative/path")); + } + + #[test] + fn resolve_path_existing_path_canonicalises() { + // /tmp should exist on any Unix system the tests run on + let result = resolve_path("/tmp"); + assert!(result.is_absolute()); + // On macOS /tmp → /private/tmp, either is fine + assert!(result.to_str().unwrap().contains("tmp")); + } + + // ── is_within ─────────────────────────────────────────────────────── + + #[test] + fn is_within_child_of_parent() { + assert!(is_within(Path::new("/a/b/c"), Path::new("/a/b"))); + } + + #[test] + fn is_within_equal_paths() { + assert!(is_within(Path::new("/a/b"), Path::new("/a/b"))); + } + + #[test] + fn is_within_rejects_prefix_without_separator() { + // /a/bc is NOT within /a/b — they share a string prefix but not a path component + assert!(!is_within(Path::new("/a/bc"), Path::new("/a/b"))); + } + + #[test] + fn is_within_root() { + assert!(is_within(Path::new("/anything"), Path::new("/"))); + } + + // ── friendly_path ─────────────────────────────────────────────────── + + #[test] + fn friendly_path_replaces_home() { + let home = home_dir().unwrap(); + let p = home.join("projects/test"); + assert_eq!(friendly_path(&p), "~/projects/test"); + } + + #[test] + fn friendly_path_bare_home() { + let home = home_dir().unwrap(); + assert_eq!(friendly_path(&home), "~"); + } + + #[test] + fn friendly_path_outside_home() { + assert_eq!(friendly_path(Path::new("/etc/hosts")), "/etc/hosts"); + } + + // ── lexical_clean ─────────────────────────────────────────────────── + + #[test] + fn lexical_clean_resolves_dots() { + assert_eq!( + lexical_clean(Path::new("/a/b/../c/./d")), + PathBuf::from("/a/c/d") + ); + } + + #[test] + fn lexical_clean_collapses_multiple_parents() { + assert_eq!( + lexical_clean(Path::new("/a/b/c/../../d")), + PathBuf::from("/a/d") + ); + } + + #[test] + fn lexical_clean_noop_on_clean_path() { + assert_eq!(lexical_clean(Path::new("/a/b/c")), PathBuf::from("/a/b/c")); + } + + // ── property-based tests ──────────────────────────────────────────── + + proptest! { + #[test] + fn resolve_path_never_panics(input in ".*") { + let _ = resolve_path(&input); + } + + #[test] + fn resolve_path_always_absolute( + input in proptest::string::string_regex("[~/a-z._]{1,30}").unwrap(), + ) { + let result = resolve_path(&input); + prop_assert!(result.is_absolute(), "resolve_path({input:?}) = {result:?} is not absolute"); + } + + #[test] + fn is_within_reflexive( + path in proptest::string::string_regex("/[a-z]{1,5}(/[a-z]{1,5}){0,3}").unwrap(), + ) { + let p = Path::new(&path); + prop_assert!(is_within(p, p)); + } + + #[test] + fn child_is_within_parent( + parent in proptest::string::string_regex("/[a-z]{1,5}(/[a-z]{1,5}){0,2}").unwrap(), + child_suffix in proptest::string::string_regex("/[a-z]{1,5}").unwrap(), + ) { + let parent_path = Path::new(&parent); + let child = format!("{parent}{child_suffix}"); + let child_path = Path::new(&child); + prop_assert!(is_within(child_path, parent_path)); + } + + #[test] + fn sibling_not_within( + base in proptest::string::string_regex("/[a-z]{1,5}").unwrap(), + a in proptest::string::string_regex("[a-z]{1,5}").unwrap(), + b in proptest::string::string_regex("[a-z]{1,5}").unwrap(), + ) { + // /base/ab is NOT within /base/a when b is non-empty — no path separator + let parent = format!("{base}/{a}"); + let sibling = format!("{base}/{a}{b}"); + if sibling != parent { + let parent_path = Path::new(&parent); + let sibling_path = Path::new(&sibling); + prop_assert!(!is_within(sibling_path, parent_path)); + } + } + } +}