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}