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