Skip to content

Commit 3e126a1

Browse files
committed
feat(permission): implement Phase 1 — complete permission system
Implement all 15 todo!() items across 4 files: - denial_tracker: consecutive/total counters with 3/20 thresholds - explainer: decision explanations + allow-rule suggestions for Bash, Edit, Read, MCP tool patterns - shadowed_rules: detect unreachable rules via tool name coverage and glob/exact content subsumption - path_validator: 6-gate validation pipeline (UTF-8 → shell expansion → UNC → NTFS bypass patterns → resolve/normalize → denied patterns → allowed dirs), symlink escape detection, tilde expansion, case-insensitive comparison on Windows/macOS 50 new tests added (260+ total in permission module).
1 parent ced8d59 commit 3e126a1

4 files changed

Lines changed: 1095 additions & 112 deletions

File tree

crates/core/src/permission/denial_tracker.rs

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88
99
use std::time::Instant;
1010

11+
// ---------------------------------------------------------------------------
12+
// Constants
13+
// ---------------------------------------------------------------------------
14+
15+
/// Maximum consecutive denials before triggering a fallback warning.
16+
const MAX_CONSECUTIVE_DENIALS: u32 = 3;
17+
18+
/// Maximum total denials before triggering a fallback warning.
19+
const MAX_TOTAL_DENIALS: usize = 20;
20+
1121
// ---------------------------------------------------------------------------
1222
// Types
1323
// ---------------------------------------------------------------------------
@@ -53,10 +63,12 @@ impl DenialTracker {
5363

5464
/// Record a new denial event.
5565
pub fn record_denial(&mut self, tool_name: &str, reason: &str) {
56-
todo!(
57-
"Record denial for tool '{tool_name}' with reason '{reason}', \
58-
increment consecutive_count, push DenialRecord"
59-
)
66+
self.consecutive_count += 1;
67+
self.denials.push(DenialRecord {
68+
tool_name: tool_name.to_string(),
69+
timestamp: Instant::now(),
70+
reason: reason.to_string(),
71+
});
6072
}
6173

6274
/// Return the number of consecutive denials since the last allow/reset.
@@ -67,14 +79,14 @@ impl DenialTracker {
6779
/// Check whether the denial count has reached a threshold that warrants
6880
/// a warning to the user or agent.
6981
///
70-
/// Default threshold: 3 consecutive denials.
82+
/// Returns `true` if consecutive denials >= 3 or total denials >= 20.
7183
pub fn should_warn(&self) -> bool {
72-
todo!("Return true if consecutive_count >= warning threshold (3)")
84+
self.consecutive_count >= MAX_CONSECUTIVE_DENIALS || self.denials.len() >= MAX_TOTAL_DENIALS
7385
}
7486

7587
/// Reset the consecutive denial counter (called after a successful allow).
7688
pub fn reset(&mut self) {
77-
todo!("Reset consecutive_count to 0")
89+
self.consecutive_count = 0;
7890
}
7991

8092
/// Return a slice of all recorded denial records.
@@ -104,9 +116,79 @@ mod tests {
104116
assert!(tracker.history().is_empty());
105117
}
106118

107-
// Additional tests to be added with implementation:
108-
// - record_denial increments consecutive_count
109-
// - should_warn returns false below threshold
110-
// - should_warn returns true at/above threshold
111-
// - reset clears consecutive_count but preserves history
119+
#[test]
120+
fn record_denial_increments_counters() {
121+
let mut tracker = DenialTracker::new();
122+
tracker.record_denial("Bash", "denied by policy");
123+
assert_eq!(tracker.consecutive_denials(), 1);
124+
assert_eq!(tracker.total_denials(), 1);
125+
assert_eq!(tracker.history()[0].tool_name, "Bash");
126+
assert_eq!(tracker.history()[0].reason, "denied by policy");
127+
}
128+
129+
#[test]
130+
fn should_warn_false_below_threshold() {
131+
let mut tracker = DenialTracker::new();
132+
tracker.record_denial("Bash", "denied");
133+
tracker.record_denial("Bash", "denied");
134+
assert!(!tracker.should_warn());
135+
}
136+
137+
#[test]
138+
fn should_warn_true_at_consecutive_threshold() {
139+
let mut tracker = DenialTracker::new();
140+
for _ in 0..3 {
141+
tracker.record_denial("Bash", "denied");
142+
}
143+
assert!(tracker.should_warn());
144+
}
145+
146+
#[test]
147+
fn should_warn_true_at_total_threshold() {
148+
let mut tracker = DenialTracker::new();
149+
for i in 0..20 {
150+
tracker.record_denial("Bash", "denied");
151+
// Reset consecutive every 2 to avoid hitting consecutive threshold
152+
if i % 2 == 1 {
153+
tracker.reset();
154+
}
155+
}
156+
assert!(tracker.should_warn());
157+
}
158+
159+
#[test]
160+
fn reset_clears_consecutive_but_preserves_history() {
161+
let mut tracker = DenialTracker::new();
162+
tracker.record_denial("Bash", "denied");
163+
tracker.record_denial("Bash", "denied");
164+
assert_eq!(tracker.consecutive_denials(), 2);
165+
assert_eq!(tracker.total_denials(), 2);
166+
167+
tracker.reset();
168+
assert_eq!(tracker.consecutive_denials(), 0);
169+
assert_eq!(tracker.total_denials(), 2); // history preserved
170+
}
171+
172+
#[test]
173+
fn reset_after_warn_clears_warning() {
174+
let mut tracker = DenialTracker::new();
175+
for _ in 0..3 {
176+
tracker.record_denial("Bash", "denied");
177+
}
178+
assert!(tracker.should_warn());
179+
180+
tracker.reset();
181+
assert!(!tracker.should_warn());
182+
}
183+
184+
#[test]
185+
fn multiple_tools_tracked() {
186+
let mut tracker = DenialTracker::new();
187+
tracker.record_denial("Bash", "policy");
188+
tracker.record_denial("Edit", "read-only mode");
189+
assert_eq!(tracker.total_denials(), 2);
190+
assert_eq!(tracker.consecutive_denials(), 2);
191+
assert_eq!(tracker.history()[0].tool_name, "Bash");
192+
assert_eq!(tracker.history()[1].tool_name, "Edit");
193+
}
112194
}

crates/core/src/permission/explainer.rs

Lines changed: 194 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! debugging permission rules and for surfacing context in the TUI.
88
99
use super::PermissionDecision;
10-
use super::rule_parser::PermissionRule;
10+
use super::rule_parser::{PermissionRule, matches_rule};
1111

1212
// ---------------------------------------------------------------------------
1313
// Types
@@ -34,40 +34,120 @@ pub struct PermissionExplanation {
3434
/// Given a tool name, the final decision, and the full set of permission rules,
3535
/// produces a [`PermissionExplanation`] describing why the decision was reached
3636
/// and what rule (if any) matched.
37-
///
38-
/// # Arguments
39-
///
40-
/// * `tool_name` - Name of the tool that was checked.
41-
/// * `decision` - The final [`PermissionDecision`] that was reached.
42-
/// * `rules` - The list of [`PermissionRule`]s that were evaluated.
43-
///
44-
/// # Examples
45-
///
46-
/// ```ignore
47-
/// let explanation = explain_decision("Bash", &PermissionDecision::Allow, &rules);
48-
/// println!("{}", explanation.decision);
49-
/// // => "Allowed: tool 'Bash' matches whitelist rule 'Bash(command:git*)'"
50-
/// ```
5137
pub fn explain_decision(
5238
tool_name: &str,
5339
decision: &PermissionDecision,
5440
rules: &[PermissionRule],
5541
) -> PermissionExplanation {
56-
todo!(
57-
"Explain why tool '{tool_name}' got decision {:?} given {} rules",
58-
decision,
59-
rules.len()
60-
)
42+
// Try to find the first matching rule for context
43+
let empty_input = serde_json::json!({});
44+
let matched = rules
45+
.iter()
46+
.find(|r| matches_rule(r, tool_name, &empty_input));
47+
48+
match decision {
49+
PermissionDecision::Allow => {
50+
if let Some(rule) = matched {
51+
PermissionExplanation {
52+
decision: format!("Allowed: tool '{tool_name}' matches rule '{rule}'"),
53+
matched_rule: Some(rule.to_string()),
54+
suggestion: None,
55+
}
56+
} else {
57+
PermissionExplanation {
58+
decision: format!(
59+
"Allowed: tool '{tool_name}' permitted by current permission mode"
60+
),
61+
matched_rule: None,
62+
suggestion: None,
63+
}
64+
}
65+
}
66+
PermissionDecision::Deny(reason) => {
67+
let suggestion = suggest_allow_rule(tool_name, &empty_input);
68+
if let Some(rule) = matched {
69+
PermissionExplanation {
70+
decision: format!(
71+
"Denied: tool '{tool_name}' blocked by rule '{rule}' — {reason}"
72+
),
73+
matched_rule: Some(rule.to_string()),
74+
suggestion,
75+
}
76+
} else {
77+
PermissionExplanation {
78+
decision: format!("Denied: tool '{tool_name}' — {reason}"),
79+
matched_rule: None,
80+
suggestion,
81+
}
82+
}
83+
}
84+
PermissionDecision::AskUser(prompt) => {
85+
let suggestion = suggest_allow_rule(tool_name, &empty_input);
86+
PermissionExplanation {
87+
decision: format!("Requires confirmation: tool '{tool_name}' — {prompt}"),
88+
matched_rule: matched.map(std::string::ToString::to_string),
89+
suggestion,
90+
}
91+
}
92+
}
6193
}
6294

6395
/// Generate a suggestion for how to allow a denied tool invocation.
6496
///
6597
/// Returns `None` if no useful suggestion can be generated.
6698
pub fn suggest_allow_rule(tool_name: &str, tool_input: &serde_json::Value) -> Option<String> {
67-
todo!(
68-
"Generate a suggestion for allowing tool '{tool_name}' with input {:?}",
69-
tool_input
70-
)
99+
// For Bash tools, suggest a command-specific allow rule if possible
100+
if tool_name == "Bash" || tool_name == "bash" {
101+
if let Some(command) = tool_input.get("command").and_then(|v| v.as_str()) {
102+
// Extract the base command (first word) for a prefix suggestion
103+
let base_cmd = command.split_whitespace().next().unwrap_or(command);
104+
return Some(format!(
105+
"Add 'Bash(command:{base_cmd}*)' to `allowed_tools` to auto-approve {base_cmd} commands"
106+
));
107+
}
108+
return Some(
109+
"Add 'Bash(*)' to `allowed_tools` to auto-approve all shell commands".to_string(),
110+
);
111+
}
112+
113+
// For Edit/Write tools, suggest a path-scoped rule if possible
114+
if (tool_name == "Edit" || tool_name == "Write" || tool_name == "edit" || tool_name == "write")
115+
&& let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str())
116+
&& let Some(parent) = std::path::Path::new(path).parent()
117+
{
118+
return Some(format!(
119+
"Add '{tool_name}(file_path:{}/**)' to `allowed_tools` to auto-approve edits in that directory",
120+
parent.display()
121+
));
122+
}
123+
124+
// For Read tools, suggest a path-scoped rule if possible
125+
if (tool_name == "Read" || tool_name == "read")
126+
&& let Some(path) = tool_input.get("file_path").and_then(|v| v.as_str())
127+
&& let Some(parent) = std::path::Path::new(path).parent()
128+
{
129+
return Some(format!(
130+
"Add 'Read(file_path:{}/**)' to `allowed_tools` to auto-approve reads in that directory",
131+
parent.display()
132+
));
133+
}
134+
135+
// For MCP tools, suggest the server-level wildcard
136+
if tool_name.starts_with("mcp__") {
137+
// Extract the server name: mcp__<server>__<tool>
138+
let parts: Vec<&str> = tool_name.splitn(3, "__").collect();
139+
if parts.len() >= 2 {
140+
let server = parts[1];
141+
return Some(format!(
142+
"Add 'mcp__{server}__*' to `allowed_tools` to auto-approve all tools from this MCP server"
143+
));
144+
}
145+
}
146+
147+
// Generic: suggest allowing the tool by name
148+
Some(format!(
149+
"Add '{tool_name}' to `allowed_tools` to auto-approve this tool"
150+
))
71151
}
72152

73153
// ---------------------------------------------------------------------------
@@ -76,10 +156,94 @@ pub fn suggest_allow_rule(tool_name: &str, tool_input: &serde_json::Value) -> Op
76156

77157
#[cfg(test)]
78158
mod tests {
79-
// Tests will be added as the implementation progresses.
80-
// Key test scenarios:
81-
// - Explain an Allow decision with a matching whitelist rule
82-
// - Explain a Deny decision with a matching denied rule
83-
// - Explain an AskUser decision (no matching rule, default behavior)
84-
// - Suggestion generation for common tool patterns
159+
use super::*;
160+
use crate::permission::rule_parser::parse_rule;
161+
162+
#[test]
163+
fn explain_allow_with_matching_rule() {
164+
// Use a tool-wide rule (no content constraint) so it matches with empty input
165+
let rules = vec![parse_rule("Bash").unwrap()];
166+
let explanation = explain_decision("Bash", &PermissionDecision::Allow, &rules);
167+
assert!(explanation.decision.contains("Allowed"));
168+
assert!(explanation.decision.contains("Bash"));
169+
assert!(explanation.matched_rule.is_some());
170+
assert!(explanation.suggestion.is_none());
171+
}
172+
173+
#[test]
174+
fn explain_allow_without_matching_rule() {
175+
let rules = vec![parse_rule("Edit").unwrap()];
176+
let explanation = explain_decision("Bash", &PermissionDecision::Allow, &rules);
177+
assert!(explanation.decision.contains("Allowed"));
178+
assert!(explanation.decision.contains("permission mode"));
179+
assert!(explanation.matched_rule.is_none());
180+
}
181+
182+
#[test]
183+
fn explain_deny_with_reason() {
184+
let rules = vec![parse_rule("Bash").unwrap()];
185+
let explanation = explain_decision(
186+
"Bash",
187+
&PermissionDecision::Deny("tool is in denied list".to_string()),
188+
&rules,
189+
);
190+
assert!(explanation.decision.contains("Denied"));
191+
assert!(explanation.decision.contains("denied list"));
192+
assert!(explanation.matched_rule.is_some());
193+
assert!(explanation.suggestion.is_some());
194+
}
195+
196+
#[test]
197+
fn explain_ask_user() {
198+
let rules = vec![];
199+
let explanation = explain_decision(
200+
"Bash",
201+
&PermissionDecision::AskUser("confirm execution".to_string()),
202+
&rules,
203+
);
204+
assert!(explanation.decision.contains("Requires confirmation"));
205+
assert!(explanation.suggestion.is_some());
206+
}
207+
208+
#[test]
209+
fn suggest_bash_command_rule() {
210+
let input = serde_json::json!({"command": "git status"});
211+
let suggestion = suggest_allow_rule("Bash", &input);
212+
assert!(suggestion.is_some());
213+
let s = suggestion.unwrap();
214+
assert!(s.contains("git"));
215+
assert!(s.contains("allowed_tools"));
216+
}
217+
218+
#[test]
219+
fn suggest_bash_generic() {
220+
let input = serde_json::json!({});
221+
let suggestion = suggest_allow_rule("Bash", &input);
222+
assert!(suggestion.is_some());
223+
assert!(suggestion.unwrap().contains("Bash(*)"));
224+
}
225+
226+
#[test]
227+
fn suggest_mcp_server_rule() {
228+
let input = serde_json::json!({});
229+
let suggestion = suggest_allow_rule("mcp__playwright__click", &input);
230+
assert!(suggestion.is_some());
231+
assert!(suggestion.unwrap().contains("mcp__playwright__*"));
232+
}
233+
234+
#[test]
235+
fn suggest_generic_tool_rule() {
236+
let input = serde_json::json!({});
237+
let suggestion = suggest_allow_rule("CustomTool", &input);
238+
assert!(suggestion.is_some());
239+
assert!(suggestion.unwrap().contains("CustomTool"));
240+
}
241+
242+
#[test]
243+
fn explain_allow_with_wildcard_rule() {
244+
let rules = vec![parse_rule("*").unwrap()];
245+
let explanation = explain_decision("AnyTool", &PermissionDecision::Allow, &rules);
246+
assert!(explanation.decision.contains("Allowed"));
247+
assert_eq!(explanation.matched_rule, Some("*".to_string()));
248+
}
85249
}

0 commit comments

Comments
 (0)