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}