Skip to main content

par_term_scripting/
protocol.rs

1//! JSON protocol types for communication between the terminal and script subprocesses.
2//!
3//! Scripts read [`ScriptEvent`] objects from stdin (one JSON object per line) and write
4//! [`ScriptCommand`] objects to stdout (one JSON object per line).
5//!
6//! # Security Model
7//!
8//! ## Trust Assumptions
9//!
10//! Scripts are user-configured subprocesses launched from `ScriptConfig` entries in
11//! `~/.config/par-term/config.yaml`. The script binary is implicitly trusted (it was
12//! placed there by the user). However, this trust must be bounded because:
13//!
14//! 1. **Supply-chain attacks**: A malicious package could replace a trusted script
15//!    with one that emits dangerous command payloads.
16//! 2. **Injection through event data**: Malicious terminal sequences could produce
17//!    events whose payloads are forwarded to the script, which could reflect them
18//!    back in commands (terminal injection risk).
19//! 3. **Compromised scripts**: A script may be modified after initial deployment.
20//!
21//! ## Command Categories
22//!
23//! Script commands fall into three security categories:
24//!
25//! ### Safe Commands (no permission required)
26//! - `Log`: Write to the script's output buffer (UI only)
27//! - `SetPanel` / `ClearPanel`: Display markdown content in a panel
28//! - `Notify`: Show a desktop notification
29//! - `SetBadge`: Set the tab badge text
30//! - `SetVariable`: Set a user variable
31//!
32//! ### Restricted Commands (require permission flags)
33//! These commands require explicit opt-in via `ScriptConfig` permission fields:
34//! - `WriteText`: Inject text into the PTY (requires `allow_write_text: true`)
35//!   - Must strip VT/ANSI escape sequences before writing
36//!   - Subject to rate limiting
37//!   - Queued for user confirmation unless `prompt_before_write_text: false`
38//!     (see [`crate::confirm`]) — stripping escapes leaves the printable text
39//!     and newline that make up a command line
40//! - `RunCommand`: Spawn an external process (requires `allow_run_command: true`)
41//!   - Must check against `check_command_denylist()` from par-term-config
42//!   - Must use shell tokenization (not `/bin/sh -c`) to prevent metacharacter injection
43//!   - Subject to rate limiting
44//! - `ChangeConfig`: Modify terminal configuration (requires `allow_change_config: true`)
45//!   - Must validate config keys against an allowlist
46//!
47//! ## Implementation Status
48//!
49//! All commands are implemented:
50//! - `Log`, `SetPanel`, `ClearPanel`: Safe, always allowed
51//! - `Notify`, `SetBadge`, `SetVariable`: Safe, always allowed
52//! - `WriteText`: Requires `allow_write_text`, rate-limited, VT sequences stripped,
53//!   and confirmed by the user unless `prompt_before_write_text` is off
54//! - `RunCommand`: Requires `allow_run_command`, rate-limited, denylist-checked,
55//!   tokenised without shell invocation
56//! - `ChangeConfig`: Requires `allow_change_config`, allowlisted keys only
57//!
58//! ## Dispatcher Responsibility
59//!
60//! The command dispatcher in `src/app/window_manager/scripting/mod.rs` is responsible for:
61//! 1. Checking `command.requires_permission()` before executing restricted commands
62//! 2. Verifying the corresponding `ScriptConfig.allow_*` flag is set
63//! 3. Applying rate limits, denylists, and input sanitization
64//! 4. Routing `WriteText` through the confirmation dialog when it is not
65//!    pre-approved
66//!
67//! See `par-term-scripting/SECURITY.md` for the complete security model.
68
69use serde::{Deserialize, Serialize};
70use std::collections::HashMap;
71
72/// An event sent from the terminal to a script subprocess (via stdin).
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct ScriptEvent {
75    /// Event kind name (e.g., "bell_rang", "cwd_changed", "command_complete").
76    pub kind: String,
77    /// Event-specific payload.
78    pub data: ScriptEventData,
79}
80
81/// Event-specific payload data.
82///
83/// Tagged with `data_type` so the JSON includes a discriminant field for Python scripts
84/// to easily dispatch on.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86#[serde(tag = "data_type")]
87pub enum ScriptEventData {
88    /// Empty payload for events that carry no additional data (e.g., BellRang).
89    Empty {},
90
91    /// The current working directory changed.
92    CwdChanged {
93        /// New working directory path.
94        cwd: String,
95    },
96
97    /// A command completed execution.
98    CommandComplete {
99        /// The command that completed.
100        command: String,
101        /// Exit code, if available.
102        exit_code: Option<i32>,
103    },
104
105    /// The terminal title changed.
106    TitleChanged {
107        /// New terminal title.
108        title: String,
109    },
110
111    /// The terminal size changed.
112    SizeChanged {
113        /// Number of columns.
114        cols: usize,
115        /// Number of rows.
116        rows: usize,
117    },
118
119    /// A user variable changed.
120    VariableChanged {
121        /// Variable name.
122        name: String,
123        /// New value.
124        value: String,
125        /// Previous value, if any.
126        old_value: Option<String>,
127    },
128
129    /// An environment variable changed.
130    EnvironmentChanged {
131        /// Environment variable key.
132        key: String,
133        /// New value.
134        value: String,
135        /// Previous value, if any.
136        old_value: Option<String>,
137    },
138
139    /// The badge text changed.
140    BadgeChanged {
141        /// New badge text, or None if cleared.
142        text: Option<String>,
143    },
144
145    /// A trigger pattern was matched.
146    TriggerMatched {
147        /// The trigger pattern that matched.
148        pattern: String,
149        /// The text that matched.
150        matched_text: String,
151        /// Line number where the match occurred.
152        line: usize,
153    },
154
155    /// A semantic zone event occurred.
156    ZoneEvent {
157        /// Zone identifier.
158        zone_id: u64,
159        /// Type of zone.
160        zone_type: String,
161        /// Event type (e.g., "enter", "exit").
162        event: String,
163    },
164
165    /// Fallback for unmapped events. Carries arbitrary key-value fields.
166    Generic {
167        /// Arbitrary event fields.
168        fields: HashMap<String, serde_json::Value>,
169    },
170}
171
172/// A command sent from a script subprocess to the terminal (via stdout).
173///
174/// Tagged with `type` for easy JSON dispatch.
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176#[serde(tag = "type")]
177pub enum ScriptCommand {
178    /// Write text to the PTY.
179    WriteText {
180        /// Text to write.
181        text: String,
182    },
183
184    /// Show a desktop notification.
185    Notify {
186        /// Notification title.
187        title: String,
188        /// Notification body.
189        body: String,
190    },
191
192    /// Set the tab badge text.
193    SetBadge {
194        /// Badge text to display.
195        text: String,
196    },
197
198    /// Set a user variable.
199    SetVariable {
200        /// Variable name.
201        name: String,
202        /// Variable value.
203        value: String,
204    },
205
206    /// Execute a shell command.
207    RunCommand {
208        /// Command to execute.
209        command: String,
210    },
211
212    /// Change a configuration value.
213    ChangeConfig {
214        /// Configuration key.
215        key: String,
216        /// New value.
217        value: serde_json::Value,
218    },
219
220    /// Log a message.
221    Log {
222        /// Log level (e.g., "info", "warn", "error", "debug").
223        level: String,
224        /// Log message.
225        message: String,
226    },
227
228    /// Set a markdown panel.
229    SetPanel {
230        /// Panel title.
231        title: String,
232        /// Markdown content.
233        content: String,
234    },
235
236    /// Clear the markdown panel.
237    ClearPanel {},
238}
239
240/// Strip VT/ANSI escape sequences from text before PTY injection.
241///
242/// Removes CSI (`ESC[`), OSC (`ESC]`), DCS (`ESC P`), APC (`ESC _`),
243/// PM (`ESC ^`), SOS (`ESC X`) sequences, and bare two-byte `ESC x`
244/// sequences. Printable characters and newlines are passed through.
245///
246/// This is required for safe `WriteText` dispatch: a script must not be
247/// able to embed control sequences that reposition the cursor, exfiltrate
248/// data, or otherwise corrupt the terminal state.
249pub fn strip_vt_sequences(text: &str) -> String {
250    let mut result = String::with_capacity(text.len());
251    let mut chars = text.chars().peekable();
252
253    while let Some(c) = chars.next() {
254        if c != '\x1b' {
255            result.push(c);
256            continue;
257        }
258        // ESC seen — classify and skip the sequence
259        match chars.peek().copied() {
260            Some('[') => {
261                // CSI: ESC [ ... <final-byte>
262                chars.next(); // consume '['
263                while let Some(&ch) = chars.peek() {
264                    chars.next();
265                    if ch.is_ascii_alphabetic() || ch == '@' || ch == '`' {
266                        break;
267                    }
268                }
269            }
270            Some(']') => {
271                // OSC: ESC ] ... BEL or ST (ESC \)
272                chars.next(); // consume ']'
273                while let Some(ch) = chars.next() {
274                    if ch == '\x07' {
275                        break;
276                    }
277                    if ch == '\x1b' && chars.peek() == Some(&'\\') {
278                        chars.next();
279                        break;
280                    }
281                }
282            }
283            Some('P') | Some('_') | Some('^') | Some('X') => {
284                // DCS / APC / PM / SOS: ESC <type> ... ST (ESC \)
285                chars.next(); // consume the type byte
286                while let Some(ch) = chars.next() {
287                    if ch == '\x1b' && chars.peek() == Some(&'\\') {
288                        chars.next();
289                        break;
290                    }
291                }
292            }
293            Some('(') | Some(')') | Some('*') | Some('+') => {
294                // Character-set designation: ESC ( x — skip two bytes
295                chars.next();
296                chars.next();
297            }
298            Some(_) => {
299                // Generic two-byte ESC sequence — skip one byte
300                chars.next();
301            }
302            None => {}
303        }
304    }
305    result
306}
307
308impl ScriptCommand {
309    /// Returns `true` if this command requires explicit permission in the script config.
310    ///
311    /// Commands that return `true` must have their corresponding `allow_*` flag set
312    /// in `ScriptConfig` before the dispatcher will execute them.
313    ///
314    /// # Security Classification
315    ///
316    /// | Command | Requires Permission | Risk Level |
317    /// |---------|--------------------| -----------|
318    /// | `Log` | No | Low (UI output only) |
319    /// | `SetPanel` / `ClearPanel` | No | Low (UI display only) |
320    /// | `Notify` | No | Low (desktop notification) |
321    /// | `SetBadge` | No | Low (tab badge display) |
322    /// | `SetVariable` | No | Low (user variable storage) |
323    /// | `WriteText` | **Yes** | High (PTY injection, command execution) |
324    /// | `RunCommand` | **Yes** | Critical (arbitrary process spawn) |
325    /// | `ChangeConfig` | **Yes** | High (config modification) |
326    pub fn requires_permission(&self) -> bool {
327        matches!(
328            self,
329            ScriptCommand::RunCommand { .. }
330                | ScriptCommand::WriteText { .. }
331                | ScriptCommand::ChangeConfig { .. }
332        )
333    }
334
335    /// Returns the name of the permission flag required to execute this command.
336    ///
337    /// Returns `None` for commands that don't require permission.
338    /// The returned string corresponds to a field in `ScriptConfig`:
339    /// - `"allow_run_command"` for `RunCommand`
340    /// - `"allow_write_text"` for `WriteText`
341    /// - `"allow_change_config"` for `ChangeConfig`
342    pub fn permission_flag_name(&self) -> Option<&'static str> {
343        match self {
344            ScriptCommand::RunCommand { .. } => Some("allow_run_command"),
345            ScriptCommand::WriteText { .. } => Some("allow_write_text"),
346            ScriptCommand::ChangeConfig { .. } => Some("allow_change_config"),
347            _ => None,
348        }
349    }
350
351    /// Returns `true` if this command can safely be executed without rate limiting.
352    ///
353    /// Commands that may be emitted frequently (like `Log`) should not be rate-limited
354    /// to avoid dropping important debug output. High-impact commands (`WriteText`,
355    /// `RunCommand`) must be rate-limited to prevent abuse.
356    pub fn is_rate_limited(&self) -> bool {
357        matches!(
358            self,
359            ScriptCommand::RunCommand { .. } | ScriptCommand::WriteText { .. }
360        )
361    }
362
363    /// Returns a human-readable name for this command type (for logging/errors).
364    pub fn command_name(&self) -> &'static str {
365        match self {
366            ScriptCommand::WriteText { .. } => "WriteText",
367            ScriptCommand::Notify { .. } => "Notify",
368            ScriptCommand::SetBadge { .. } => "SetBadge",
369            ScriptCommand::SetVariable { .. } => "SetVariable",
370            ScriptCommand::RunCommand { .. } => "RunCommand",
371            ScriptCommand::ChangeConfig { .. } => "ChangeConfig",
372            ScriptCommand::Log { .. } => "Log",
373            ScriptCommand::SetPanel { .. } => "SetPanel",
374            ScriptCommand::ClearPanel {} => "ClearPanel",
375        }
376    }
377}