Skip to main content

forge_primitives/
permission_interaction.rs

1//! The shapes one permission interaction is made of - `PermissionRequest`
2//! (what the agent surfaces when the CLI asks for tool approval), the
3//! options it offers, and `PermissionOutcome` (what a person picks).
4//!
5//! Not a view's shapes, and the name says so: the request and its options
6//! are what the dispatcher reads to build the decision on submit, so the
7//! dock a view draws is a rendering of one set rather than a copy of it.
8//! Distinct from the wire-side decision
9//! types in [`crate::permissions`], which talk to the SDK's
10//! `can_use_tool` callback.
11
12use serde::{Deserialize, Serialize};
13
14use crate::permissions::PermissionUpdate;
15use crate::session_update::ToolCall;
16
17/// Typed wire-side response routing for a permission option. Each
18/// `PermissionOption` constructed by `forge-agent` carries an
19/// `action`; the dispatcher reads it to build the right
20/// `PermissionDecision` on submit.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "snake_case")]
23pub enum PermissionAction {
24    /// `PermissionDecision::allow()`.
25    Allow,
26    /// `PermissionDecision::allow().with_updated_permissions(updates)`.
27    AllowWithUpdates { updates: Vec<PermissionUpdate> },
28    /// Marker - the actual edited input value lives on
29    /// the view's own prompt state. Dispatcher sends
30    /// `PermissionDecision::allow_with_input(edited_value)`.
31    AllowWithInput,
32    /// `PermissionDecision::deny(reason)` where reason is the user's
33    /// notes text or a default when notes are empty.
34    Deny,
35}
36
37/// Display style for a permission option - drives icon + color in the
38/// prompt widget.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PermissionOptionKind {
42    /// Allow paths (✓ green icon).
43    Allow,
44    /// Deny paths (✗ red icon).
45    Deny,
46    /// Allow-with-edits path (✎ blue icon).
47    Edit,
48    /// Forge-synthesized "Tell the agent something else" escape hatch (… dim icon).
49    Notes,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct PermissionOption {
54    pub option_id: String,
55    pub name: String,
56    pub kind: PermissionOptionKind,
57    pub action: PermissionAction,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct PermissionRequest {
62    pub tool_call: ToolCall,
63    pub options: Vec<PermissionOption>,
64    pub display: Option<PermissionDisplay>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
68pub struct PermissionDisplay {
69    pub title: Option<String>,
70    pub display_name: Option<String>,
71    pub description: Option<String>,
72    /// The CLI's reason for asking (`decision_reason` from CanUseTool).
73    /// Rendered as the yellow ⚠ line in the dock header.
74    pub decision_reason: Option<String>,
75}
76
77impl PermissionDisplay {
78    /// `true` iff every field is None or contains only whitespace.
79    pub fn is_empty(&self) -> bool {
80        self.title.as_ref().is_none_or(|value| value.trim().is_empty())
81            && self.display_name.as_ref().is_none_or(|value| value.trim().is_empty())
82            && self.description.as_ref().is_none_or(|value| value.trim().is_empty())
83            && self.decision_reason.as_ref().is_none_or(|value| value.trim().is_empty())
84    }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(tag = "outcome", rename_all = "snake_case")]
89pub enum PermissionOutcome {
90    Selected {
91        option_id: String,
92        /// The dispatch routing for this option, as set by the agent
93        /// when constructing the wire `PermissionOption`. The agent's
94        /// response handler reads this directly - no option_id lookup
95        /// or string-prefix matching needed.
96        action: PermissionAction,
97        /// User's "tell the agent" feedback string (when the notes-option
98        /// was toggled or the focused option is a Deny). Empty when the
99        /// user picked a plain Allow without notes.
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        notes_text: Option<String>,
102        /// Edited tool input JSON when the action is `AllowWithInput`.
103        /// None otherwise.
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        edited_input: Option<serde_json::Value>,
106    },
107    Cancelled,
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::permission::PermissionMode;
114    use crate::permissions::PermissionUpdate;
115
116    #[test]
117    fn permission_action_round_trips_through_serde() {
118        let allow_with_updates = PermissionAction::AllowWithUpdates {
119            updates: vec![PermissionUpdate::SetMode {
120                mode: PermissionMode::Auto,
121                destination: None,
122            }],
123        };
124        let json = serde_json::to_string(&allow_with_updates).expect("serialize");
125        let back: PermissionAction = serde_json::from_str(&json).expect("deserialize");
126        assert_eq!(allow_with_updates, back);
127    }
128
129    #[test]
130    fn permission_outcome_selected_round_trips_with_notes_and_edited_input() {
131        let outcome = PermissionOutcome::Selected {
132            option_id: "allow_with_edits".into(),
133            action: PermissionAction::AllowWithInput,
134            notes_text: Some("trimmed args".to_owned()),
135            edited_input: Some(serde_json::json!({"command": "echo trimmed"})),
136        };
137        let json = serde_json::to_string(&outcome).expect("serialize");
138        let back: PermissionOutcome = serde_json::from_str(&json).expect("deserialize");
139        assert_eq!(outcome, back);
140    }
141}