Skip to main content

forge_sdk/
permissions.rs

1//! Permission callback trait + tests. Wire-data lives in
2//! forge-primitives - consumers import from there directly.
3
4use forge_primitives::{PermissionDecision, ToolPermissionContext};
5
6/// Trait for permission callbacks.
7///
8/// Implementations are typically async closures or plain functions. The
9/// SDK wraps them in a boxed trait object inside `Options`.
10///
11/// # Panics and error handling
12///
13/// Callbacks are expected to never panic. If a callback does panic, the
14/// `tokio` task running the current `next_event` call is aborted; the next
15/// call to `next_event` returns an [`Error::Io`](crate::Error::Io) with a
16/// broken-pipe or similar message. Authors should return
17/// [`PermissionDecision::deny`] to signal rejection rather than panicking.
18///
19/// Callbacks cannot signal I/O or other errors. If your callback performs
20/// fallible work (e.g., consulting a policy server), handle the failure
21/// internally and translate to `allow` or `deny(reason)` - the SDK does not
22/// surface callback errors to the `claude` binary separately from a deny
23/// response.
24pub trait CanUseToolCallback: Send + Sync {
25    /// Called by the SDK when the `claude` binary requests permission.
26    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    // Test-mod `use super::*;` brings the parent's full surface in; not every test consumes every item.
48    #[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        // Still a deny; permissions are not readable.
248        assert!(d.updated_permissions().is_empty());
249        assert_eq!(d.reason(), Some("nope"));
250    }
251}