1use forge_primitives::{PermissionDecision, ToolPermissionContext};
5
6pub trait CanUseToolCallback: Send + Sync {
25 fn call<'a>(
27 &'a self,
28 ctx: ToolPermissionContext,
29 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = PermissionDecision> + Send + 'a>>;
30}
31
32impl<F, Fut> CanUseToolCallback for F
33where
34 F: Fn(ToolPermissionContext) -> Fut + Send + Sync,
35 Fut: std::future::Future<Output = PermissionDecision> + Send + 'static,
36{
37 fn call<'a>(
38 &'a self,
39 ctx: ToolPermissionContext,
40 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = PermissionDecision> + Send + 'a>> {
41 Box::pin(self(ctx))
42 }
43}
44
45#[cfg(test)]
46mod tests {
47 #[allow(unused_imports)]
49 use super::*;
50
51 use forge_primitives::{PermissionDecision, ToolPermissionContext};
52 use serde_json::json;
53
54 #[test]
55 fn allow_decision_no_modifications() {
56 let d = PermissionDecision::allow();
57 assert!(d.is_allow());
58 assert!(d.updated_input().is_none());
59 assert!(d.reason().is_none());
60 }
61
62 #[test]
63 fn allow_decision_with_updated_input() {
64 let d = PermissionDecision::allow_with_input(json!({"file_path": "/tmp/safe.txt"}));
65 assert!(d.is_allow());
66 assert_eq!(d.updated_input().unwrap(), &json!({"file_path": "/tmp/safe.txt"}));
67 }
68
69 #[test]
70 fn deny_decision_carries_reason() {
71 let d = PermissionDecision::deny("not today");
72 assert!(!d.is_allow());
73 assert_eq!(d.reason(), Some("not today"));
74 }
75
76 #[test]
77 fn context_carries_tool_name_and_input() {
78 let ctx =
79 ToolPermissionContext::new("Edit", json!({"file_path": "/tmp/x"}), "toolu_01", None);
80 assert_eq!(ctx.tool_name, "Edit");
81 assert_eq!(ctx.tool_use_id, "toolu_01");
82 }
83
84 use crate::PermissionMode;
85 use forge_primitives::{
86 PermissionBehavior, PermissionRuleValue, PermissionUpdate, PermissionUpdateDestination,
87 };
88
89 #[test]
90 fn add_rules_serialises_with_camel_case_wire_keys() {
91 let update = PermissionUpdate::AddRules {
92 rules: vec![
93 PermissionRuleValue { tool_name: "Edit".into(), rule_content: Some("*.py".into()) },
94 PermissionRuleValue { tool_name: "Bash".into(), rule_content: None },
95 ],
96 behavior: PermissionBehavior::Allow,
97 destination: Some(PermissionUpdateDestination::ProjectSettings),
98 };
99 let v = serde_json::to_value(&update).expect("ser");
100 assert_eq!(
101 v,
102 json!({
103 "type": "addRules",
104 "rules": [
105 {"toolName": "Edit", "ruleContent": "*.py"},
106 {"toolName": "Bash"}
107 ],
108 "behavior": "allow",
109 "destination": "projectSettings"
110 })
111 );
112 }
113
114 #[test]
115 fn replace_and_remove_rules_variants_roundtrip() {
116 for (variant, wire_type) in [
117 (
118 PermissionUpdate::ReplaceRules {
119 rules: vec![PermissionRuleValue {
120 tool_name: "Edit".into(),
121 rule_content: None,
122 }],
123 behavior: PermissionBehavior::Deny,
124 destination: None,
125 },
126 "replaceRules",
127 ),
128 (
129 PermissionUpdate::RemoveRules {
130 rules: vec![PermissionRuleValue {
131 tool_name: "Bash".into(),
132 rule_content: Some("rm *".into()),
133 }],
134 behavior: PermissionBehavior::Ask,
135 destination: Some(PermissionUpdateDestination::Session),
136 },
137 "removeRules",
138 ),
139 ] {
140 let v = serde_json::to_value(&variant).expect("ser");
141 assert_eq!(v["type"], wire_type);
142 let back: PermissionUpdate = serde_json::from_value(v).expect("de");
143 assert_eq!(
144 serde_json::to_value(&back).unwrap(),
145 serde_json::to_value(&variant).unwrap()
146 );
147 }
148 }
149
150 #[test]
151 fn set_mode_serialises_with_camel_case_mode_string() {
152 let update = PermissionUpdate::SetMode {
153 mode: PermissionMode::AcceptEdits,
154 destination: Some(PermissionUpdateDestination::UserSettings),
155 };
156 let v = serde_json::to_value(&update).expect("ser");
157 assert_eq!(
158 v,
159 json!({
160 "type": "setMode",
161 "mode": "acceptEdits",
162 "destination": "userSettings"
163 })
164 );
165 }
166
167 #[test]
168 fn directory_variants_roundtrip() {
169 let add = PermissionUpdate::AddDirectories {
170 directories: vec!["/tmp/a".into(), "/tmp/b".into()],
171 destination: None,
172 };
173 let v = serde_json::to_value(&add).expect("ser");
174 assert_eq!(
175 v,
176 json!({
177 "type": "addDirectories",
178 "directories": ["/tmp/a", "/tmp/b"]
179 })
180 );
181
182 let rem = PermissionUpdate::RemoveDirectories {
183 directories: vec!["/tmp/a".into()],
184 destination: Some(PermissionUpdateDestination::LocalSettings),
185 };
186 let v = serde_json::to_value(&rem).expect("ser");
187 assert_eq!(v["type"], "removeDirectories");
188 assert_eq!(v["destination"], "localSettings");
189 }
190
191 #[test]
192 fn permission_behavior_values_match_wire() {
193 for (b, wire) in [
194 (PermissionBehavior::Allow, "allow"),
195 (PermissionBehavior::Deny, "deny"),
196 (PermissionBehavior::Ask, "ask"),
197 ] {
198 assert_eq!(serde_json::to_value(b).unwrap(), json!(wire));
199 }
200 }
201
202 #[test]
203 fn permission_update_destination_values_match_wire() {
204 for (d, wire) in [
205 (PermissionUpdateDestination::UserSettings, "userSettings"),
206 (PermissionUpdateDestination::ProjectSettings, "projectSettings"),
207 (PermissionUpdateDestination::LocalSettings, "localSettings"),
208 (PermissionUpdateDestination::Session, "session"),
209 ] {
210 assert_eq!(serde_json::to_value(d).unwrap(), json!(wire));
211 }
212 }
213
214 #[test]
215 fn decision_allow_has_empty_permissions_by_default() {
216 let d = PermissionDecision::allow();
217 assert!(d.updated_permissions().is_empty());
218 }
219
220 #[test]
221 fn decision_allow_with_input_has_empty_permissions_by_default() {
222 let d = PermissionDecision::allow_with_input(json!({"foo": "bar"}));
223 assert!(d.updated_permissions().is_empty());
224 assert_eq!(d.updated_input(), Some(&json!({"foo": "bar"})));
225 }
226
227 #[test]
228 fn with_updated_permissions_attaches_updates() {
229 let updates = vec![
230 PermissionUpdate::SetMode { mode: PermissionMode::Plan, destination: None },
231 PermissionUpdate::AddDirectories {
232 directories: vec!["/workspace".into()],
233 destination: None,
234 },
235 ];
236 let d = PermissionDecision::allow().with_updated_permissions(updates.clone());
237 let got = d.updated_permissions();
238 assert_eq!(got.len(), 2);
239 assert_eq!(serde_json::to_value(got).unwrap(), serde_json::to_value(&updates).unwrap());
240 }
241
242 #[test]
243 fn with_updated_permissions_is_noop_on_deny() {
244 let d = PermissionDecision::deny("nope").with_updated_permissions(vec![
245 PermissionUpdate::SetMode { mode: PermissionMode::Ask, destination: None },
246 ]);
247 assert!(d.updated_permissions().is_empty());
249 assert_eq!(d.reason(), Some("nope"));
250 }
251}