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}