Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ exclude = [
]

[dependencies]

[dev-dependencies]
proptest = "1"
147 changes: 147 additions & 0 deletions src/decision.rs
Original file line number Diff line number Diff line change
@@ -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<String>, layer: &'static str) -> Self {
Self {
decision,
reason: reason.into(),
layer,
tool_name: String::new(),
}
}

pub fn with_tool(mut self, tool: impl Into<String>) -> 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);
}
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/// Holdit — a safety harness for AI coding agents.
pub mod decision;
pub mod path_util;
Loading