Skip to main content

forge_primitives/
permissions.rs

1//! Permission decision wire-data - `ToolPermissionContext`,
2//! `PermissionDecision`, `PermissionUpdate`,
3//! `PermissionUpdateDestination`, `PermissionBehavior`,
4//! `PermissionRuleValue`. The matching `CanUseToolCallback` trait
5//! stays SDK-side because it owns function-pointer dispatch.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::options::PermissionMode;
11
12#[derive(Debug, Clone)]
13pub struct ToolPermissionContext {
14    /// The tool the model wants to invoke (e.g. `"Edit"`, `"Bash"`).
15    pub tool_name: String,
16    /// The JSON input the model generated for the call.
17    pub tool_input: Value,
18    /// Identifier of THIS tool call (always present - used to correlate
19    /// with the subsequent `ToolResult` in the stream). Matches the wire
20    /// `tool_use_id` field.
21    pub tool_use_id: String,
22    /// Sub-agent identifier when the request came from a Task-spawned
23    /// child agent. `None` for main-agent tool calls.
24    pub agent_id: Option<String>,
25    /// Permission-rule suggestions the CLI attached to this request.
26    /// Typically populated when the user has in-flight workspace
27    /// permission prompts. Wire shape:
28    /// `ToolPermissionContext.suggestions` - populated
29    /// from the `control_request`'s `permission_suggestions` list, with
30    /// unrecognised entries dropped.
31    pub suggestions: Vec<PermissionUpdate>,
32    /// Path the CLI considered out-of-bounds when rejecting the tool
33    /// call (e.g. an `Edit` against a file outside the workspace).
34    /// `None` when the request is not path-scoped or the CLI didn't
35    /// supply a value.
36    pub blocked_path: Option<String>,
37    /// Free-form reason the CLI surfaced for why the request needs
38    /// human review (e.g. `"workspace not yet trusted"`). Pass-through
39    /// for UIs that show it verbatim.
40    pub decision_reason: Option<String>,
41    /// Short title the CLI suggests for the prompt (e.g. `"Run tests"`).
42    pub title: Option<String>,
43    /// Display name for the tool call (often a humanised tool name).
44    pub display_name: Option<String>,
45    /// Long-form description the CLI suggests as additional context
46    /// in the prompt body.
47    pub description: Option<String>,
48}
49
50impl ToolPermissionContext {
51    /// Construct a context. `suggestions` defaults to empty; use
52    /// [`with_suggestions`](Self::with_suggestions) to attach parsed
53    /// permission-rule hints.
54    pub fn new(
55        tool_name: impl Into<String>,
56        tool_input: Value,
57        tool_use_id: impl Into<String>,
58        agent_id: Option<String>,
59    ) -> Self {
60        Self {
61            tool_name: tool_name.into(),
62            tool_input,
63            tool_use_id: tool_use_id.into(),
64            agent_id,
65            suggestions: Vec::new(),
66            blocked_path: None,
67            decision_reason: None,
68            title: None,
69            display_name: None,
70            description: None,
71        }
72    }
73
74    /// Attach permission-rule suggestions parsed from the control
75    /// request's `permission_suggestions` field.
76    pub fn with_suggestions(mut self, suggestions: Vec<PermissionUpdate>) -> Self {
77        self.suggestions = suggestions;
78        self
79    }
80
81    /// Attach the optional display fields the CLI surfaces alongside
82    /// `tool_name` / `tool_input` so UIs can render rich permission
83    /// prompts ("the agent wants to `<title>`: `<description>`"). Each field
84    /// is independently optional; callers pass `None` for ones the CLI
85    /// didn't populate.
86    pub fn with_display(
87        mut self,
88        blocked_path: Option<String>,
89        decision_reason: Option<String>,
90        title: Option<String>,
91        display_name: Option<String>,
92        description: Option<String>,
93    ) -> Self {
94        self.blocked_path = blocked_path;
95        self.decision_reason = decision_reason;
96        self.title = title;
97        self.display_name = display_name;
98        self.description = description;
99        self
100    }
101}
102
103/// The decision a callback returns.
104///
105/// Wraps the CLI's `PermissionResultAllow` / `PermissionResultDeny` as a
106/// single enum with constructor helpers.
107#[derive(Debug, Clone)]
108pub struct PermissionDecision {
109    inner: DecisionKind,
110}
111
112#[derive(Debug, Clone)]
113enum DecisionKind {
114    Allow { updated_input: Option<Value>, updated_permissions: Vec<PermissionUpdate> },
115    Deny { reason: String },
116}
117
118impl PermissionDecision {
119    /// Approve the tool call as-is.
120    pub fn allow() -> Self {
121        Self { inner: DecisionKind::Allow { updated_input: None, updated_permissions: Vec::new() } }
122    }
123
124    /// Approve the tool call with a modified input payload. The `claude`
125    /// binary will receive the modified input in place of the model's
126    /// original.
127    pub fn allow_with_input(updated_input: Value) -> Self {
128        Self {
129            inner: DecisionKind::Allow {
130                updated_input: Some(updated_input),
131                updated_permissions: Vec::new(),
132            },
133        }
134    }
135
136    /// Deny the tool call. `reason` is forwarded to the model as feedback.
137    pub fn deny(reason: impl Into<String>) -> Self {
138        Self { inner: DecisionKind::Deny { reason: reason.into() } }
139    }
140
141    /// Attach a list of [`PermissionUpdate`]s to an allow decision. These
142    /// are forwarded to the `claude` binary as `updatedPermissions` on the
143    /// wire and applied to the session's permission state. No-op on a
144    /// deny decision - the CLI's `PermissionResultDeny` has no equivalent
145    /// channel.
146    pub fn with_updated_permissions(mut self, updates: Vec<PermissionUpdate>) -> Self {
147        if let DecisionKind::Allow { updated_permissions, .. } = &mut self.inner {
148            *updated_permissions = updates;
149        }
150        self
151    }
152
153    /// True if this is an allow decision.
154    pub fn is_allow(&self) -> bool {
155        matches!(self.inner, DecisionKind::Allow { .. })
156    }
157
158    /// For an allow decision with modified input, returns the modified input.
159    pub fn updated_input(&self) -> Option<&Value> {
160        match &self.inner {
161            DecisionKind::Allow { updated_input, .. } => updated_input.as_ref(),
162            DecisionKind::Deny { .. } => None,
163        }
164    }
165
166    /// Permission updates attached to an allow decision. Empty slice for
167    /// deny, or for an allow that carries no updates.
168    pub fn updated_permissions(&self) -> &[PermissionUpdate] {
169        match &self.inner {
170            DecisionKind::Allow { updated_permissions, .. } => updated_permissions,
171            DecisionKind::Deny { .. } => &[],
172        }
173    }
174
175    /// For a deny decision, returns the reason string.
176    pub fn reason(&self) -> Option<&str> {
177        match &self.inner {
178            DecisionKind::Deny { reason } => Some(reason.as_str()),
179            DecisionKind::Allow { .. } => None,
180        }
181    }
182}
183
184/// Where a [`PermissionUpdate`] should be persisted. Wire shape:
185/// `PermissionUpdateDestination` literal.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub enum PermissionUpdateDestination {
189    /// User-level `<config_dir>/settings.json`.
190    UserSettings,
191    /// Project-level `.claude/settings.json`.
192    ProjectSettings,
193    /// Project-local `.claude/settings.local.json`.
194    LocalSettings,
195    /// Session-scoped (in-memory, per-client) - discarded on disconnect.
196    Session,
197}
198
199/// Policy a rule-based [`PermissionUpdate`] applies. Wire shape:
200/// `PermissionBehavior` literal.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "lowercase")]
203pub enum PermissionBehavior {
204    /// Auto-approve matches.
205    Allow,
206    /// Auto-deny matches.
207    Deny,
208    /// Prompt on matches.
209    Ask,
210}
211
212/// One tool-rule entry inside a rule-based [`PermissionUpdate`].
213/// Wraps the CLI's `PermissionRuleValue`. Wire uses
214/// camelCase (`toolName`, `ruleContent`) per the CLI's `to_dict`.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct PermissionRuleValue {
218    /// Tool the rule targets (e.g. `"Edit"`, `"Bash"`).
219    pub tool_name: String,
220    /// Optional rule-content pattern (tool-specific). `None` means "any
221    /// invocation of this tool".
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub rule_content: Option<String>,
224}
225
226/// One permission-state mutation attached to an allow decision. Mirrors
227/// the CLI's `PermissionUpdate`. Dispatched on `type`.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(tag = "type")]
230pub enum PermissionUpdate {
231    /// Append rules to the active permission set.
232    #[serde(rename = "addRules")]
233    AddRules {
234        /// Rules to add.
235        rules: Vec<PermissionRuleValue>,
236        /// Policy for matching invocations.
237        behavior: PermissionBehavior,
238        /// Where to persist. `None` = in-memory only.
239        #[serde(default, skip_serializing_if = "Option::is_none")]
240        destination: Option<PermissionUpdateDestination>,
241    },
242    /// Replace the entire rule set with `rules`.
243    #[serde(rename = "replaceRules")]
244    ReplaceRules {
245        /// Rules to install.
246        rules: Vec<PermissionRuleValue>,
247        /// Policy for matching invocations.
248        behavior: PermissionBehavior,
249        /// Where to persist.
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        destination: Option<PermissionUpdateDestination>,
252    },
253    /// Remove the listed rules from the active set.
254    #[serde(rename = "removeRules")]
255    RemoveRules {
256        /// Rules to drop.
257        rules: Vec<PermissionRuleValue>,
258        /// Policy the rules were registered under.
259        behavior: PermissionBehavior,
260        /// Where to persist the removal.
261        #[serde(default, skip_serializing_if = "Option::is_none")]
262        destination: Option<PermissionUpdateDestination>,
263    },
264    /// Switch the session's [`PermissionMode`].
265    #[serde(rename = "setMode")]
266    SetMode {
267        /// Target mode.
268        mode: PermissionMode,
269        /// Where to persist.
270        #[serde(default, skip_serializing_if = "Option::is_none")]
271        destination: Option<PermissionUpdateDestination>,
272    },
273    /// Widen the additional-directories allowlist.
274    #[serde(rename = "addDirectories")]
275    AddDirectories {
276        /// Absolute paths to add.
277        directories: Vec<String>,
278        /// Where to persist.
279        #[serde(default, skip_serializing_if = "Option::is_none")]
280        destination: Option<PermissionUpdateDestination>,
281    },
282    /// Shrink the additional-directories allowlist.
283    #[serde(rename = "removeDirectories")]
284    RemoveDirectories {
285        /// Absolute paths to remove.
286        directories: Vec<String>,
287        /// Where to persist the removal.
288        #[serde(default, skip_serializing_if = "Option::is_none")]
289        destination: Option<PermissionUpdateDestination>,
290    },
291}