use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrettifyScope {
Line,
Block,
#[default]
CommandOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrettifyRelayPayload {
pub format: String,
pub scope: PrettifyScope,
#[serde(default)]
pub block_end: Option<String>,
#[serde(default)]
pub sub_format: Option<String>,
#[serde(default)]
pub command_filter: Option<String>,
}
pub const PRETTIFY_RELAY_PREFIX: &str = "__prettify__";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TriggerConfig {
pub name: String,
pub pattern: String,
#[serde(default = "crate::defaults::bool_true")]
pub enabled: bool,
#[serde(default)]
pub actions: Vec<TriggerActionConfig>,
#[serde(default = "crate::defaults::bool_true", alias = "require_user_action")]
pub prompt_before_run: bool,
#[serde(default)]
pub i_accept_the_risk: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerActionConfig {
Highlight {
#[serde(default)]
fg: Option<[u8; 3]>,
#[serde(default)]
bg: Option<[u8; 3]>,
#[serde(default = "default_highlight_duration")]
duration_ms: u64,
},
Notify {
title: String,
message: String,
},
MarkLine {
#[serde(default)]
label: Option<String>,
#[serde(default)]
color: Option<[u8; 3]>,
},
SetVariable {
name: String,
value: String,
},
RunCommand {
command: String,
#[serde(default)]
args: Vec<String>,
},
PlaySound {
#[serde(default)]
sound_id: String,
#[serde(default = "default_volume")]
volume: u8,
},
SendText {
text: String,
#[serde(default)]
delay_ms: u64,
},
Prettify {
format: String,
#[serde(default)]
scope: PrettifyScope,
#[serde(default)]
block_end: Option<String>,
#[serde(default)]
sub_format: Option<String>,
#[serde(default)]
command_filter: Option<String>,
},
SplitPane {
direction: TriggerSplitDirection,
#[serde(default)]
command: Option<SplitPaneCommand>,
#[serde(default = "crate::defaults::bool_true")]
focus_new_pane: bool,
#[serde(default)]
target: TriggerSplitTarget,
#[serde(default = "default_split_percent")]
split_percent: u8,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TriggerSplitDirection {
Horizontal, Vertical, }
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TriggerSplitTarget {
#[default]
Active, Source, }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SplitPaneCommand {
SendText {
text: String,
#[serde(default = "default_split_send_delay")]
delay_ms: u64,
},
InitialCommand {
command: String,
#[serde(default)]
args: Vec<String>,
},
}
fn default_split_send_delay() -> u64 {
200
}
fn default_split_percent() -> u8 {
66
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RestartPolicy {
#[default]
Never,
Always,
OnFailure,
}
impl RestartPolicy {
pub fn all() -> &'static [RestartPolicy] {
&[Self::Never, Self::Always, Self::OnFailure]
}
pub fn display_name(self) -> &'static str {
match self {
Self::Never => "Never",
Self::Always => "Always",
Self::OnFailure => "On Failure",
}
}
pub fn to_core(self) -> par_term_emu_core_rust::coprocess::RestartPolicy {
match self {
Self::Never => par_term_emu_core_rust::coprocess::RestartPolicy::Never,
Self::Always => par_term_emu_core_rust::coprocess::RestartPolicy::Always,
Self::OnFailure => par_term_emu_core_rust::coprocess::RestartPolicy::OnFailure,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CoprocessDefConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub auto_start: bool,
#[serde(default = "crate::defaults::bool_true")]
pub copy_terminal_output: bool,
#[serde(default)]
pub restart_policy: RestartPolicy,
#[serde(default)]
pub restart_delay_ms: u64,
}
fn default_highlight_duration() -> u64 {
5000
}
fn default_volume() -> u8 {
50
}
impl TriggerActionConfig {
pub fn is_dangerous(&self) -> bool {
matches!(
self,
Self::RunCommand { .. } | Self::SendText { .. } | Self::SplitPane { .. }
)
}
pub fn to_core_action(&self) -> par_term_emu_core_rust::terminal::TriggerAction {
use par_term_emu_core_rust::terminal::TriggerAction;
match self.clone() {
Self::Highlight {
fg,
bg,
duration_ms,
} => TriggerAction::Highlight {
fg: fg.map(|c| (c[0], c[1], c[2])),
bg: bg.map(|c| (c[0], c[1], c[2])),
duration_ms,
},
Self::Notify { title, message } => TriggerAction::Notify { title, message },
Self::MarkLine { label, color } => TriggerAction::MarkLine {
label,
color: color.map(|c| (c[0], c[1], c[2])),
},
Self::SetVariable { name, value } => TriggerAction::SetVariable { name, value },
Self::RunCommand { command, args } => TriggerAction::RunCommand { command, args },
Self::PlaySound { sound_id, volume } => TriggerAction::PlaySound { sound_id, volume },
Self::SendText { text, delay_ms } => TriggerAction::SendText { text, delay_ms },
Self::Prettify {
format,
scope,
block_end,
sub_format,
command_filter,
} => {
let payload = PrettifyRelayPayload {
format,
scope,
block_end,
sub_format,
command_filter,
};
TriggerAction::MarkLine {
label: Some(format!(
"{}{}",
PRETTIFY_RELAY_PREFIX,
serde_json::to_string(&payload).unwrap_or_default()
)),
color: None,
}
}
Self::SplitPane {
direction,
command,
focus_new_pane,
target,
split_percent: _,
} => {
let core_direction = match direction {
crate::automation::TriggerSplitDirection::Horizontal => {
par_term_emu_core_rust::terminal::TriggerSplitDirection::Horizontal
}
crate::automation::TriggerSplitDirection::Vertical => {
par_term_emu_core_rust::terminal::TriggerSplitDirection::Vertical
}
};
let core_command = command.map(|c| match c {
crate::automation::SplitPaneCommand::SendText { text, delay_ms } => {
par_term_emu_core_rust::terminal::TriggerSplitCommand::SendText {
text,
delay_ms,
}
}
crate::automation::SplitPaneCommand::InitialCommand { command, args } => {
par_term_emu_core_rust::terminal::TriggerSplitCommand::InitialCommand {
command,
args,
}
}
});
let core_target = match target {
crate::automation::TriggerSplitTarget::Active => {
par_term_emu_core_rust::terminal::TriggerSplitTarget::Active
}
crate::automation::TriggerSplitTarget::Source => {
par_term_emu_core_rust::terminal::TriggerSplitTarget::Source
}
};
TriggerAction::SplitPane {
direction: core_direction,
command: core_command,
focus_new_pane,
target: core_target,
}
}
}
}
}
const DENIED_COMMAND_PATTERNS: &[&str] = &[
"rm -rf /",
"rm -rf ~",
"rm -rf .",
"mkfs.",
"dd if=",
"eval ",
"exec ",
"ssh-add",
".ssh/id_",
".ssh/authorized_keys",
".gnupg/",
"chmod 777",
"chown root",
"passwd",
"sudoers",
];
const BYPASS_WRAPPER_PATTERNS: &[&str] = &[
"env ",
"/usr/bin/env ",
"/bin/env ",
"sh -c ",
"bash -c ",
"zsh -c ",
"fish -c ",
"dash -c ",
"ksh -c ",
"csh -c ",
"tcsh -c ",
];
const PIPE_SHELL_TARGETS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
pub fn check_command_denylist(command: &str, args: &[String]) -> Option<&'static str> {
let full_command = if args.is_empty() {
command.to_lowercase()
} else {
format!("{} {}", command, args.join(" ")).to_lowercase()
};
let mut check_strings = vec![full_command.clone()];
for arg in args {
let lowered = arg.to_lowercase();
if !lowered.is_empty() {
check_strings.push(lowered);
}
}
for wrapper in BYPASS_WRAPPER_PATTERNS {
let normalized_wrapper = wrapper.to_lowercase();
if full_command.starts_with(&normalized_wrapper) {
let remainder = full_command[normalized_wrapper.len()..].trim().to_string();
if !remainder.is_empty() {
check_strings.push(remainder);
}
if normalized_wrapper.contains(" -c ") || normalized_wrapper.ends_with(" -c") {
return Some("shell -c wrapper");
}
}
for arg in args {
let lowered_arg = arg.to_lowercase();
if lowered_arg.starts_with(&normalized_wrapper)
&& (normalized_wrapper.contains(" -c ") || normalized_wrapper.ends_with(" -c"))
{
return Some("shell -c wrapper");
}
}
}
for pattern in DENIED_COMMAND_PATTERNS {
let normalized_pattern = pattern.to_lowercase();
for check_str in &check_strings {
if check_str.contains(&normalized_pattern) {
return Some(pattern);
}
}
}
for check_str in &check_strings {
for &shell in PIPE_SHELL_TARGETS {
if check_pipe_to_shell(check_str, shell) {
return match shell {
"bash" => Some("| bash"),
"sh" => Some("| sh"),
"zsh" => Some("| zsh"),
"fish" => Some("| fish"),
"dash" => Some("| dash"),
"ksh" => Some("| ksh"),
_ => Some("| <shell>"),
};
}
}
}
None
}
fn check_pipe_to_shell(s: &str, shell: &str) -> bool {
for sep in &["|", "| "] {
let pattern = format!("{}{}", sep, shell);
if let Some(pos) = s.find(&pattern) {
let end_pos = pos + pattern.len();
if end_pos >= s.len() || !s.as_bytes()[end_pos].is_ascii_alphanumeric() {
return true;
}
}
}
false
}
pub fn warn_prompt_before_run_false(trigger_name: &str, i_accept_the_risk: bool) {
if i_accept_the_risk {
eprintln!(
"[par-term SECURITY WARNING] Trigger '{trigger_name}' has `prompt_before_run: false`.\n\
This allows terminal output to directly trigger RunCommand/SendText/SplitPane actions\n\
without confirmation. The command denylist provides only limited protection.\n\
Only use this setting if you fully trust the configured commands and environment.\n\
Recommendation: set `prompt_before_run: true` (the default) to require confirmation."
);
} else {
eprintln!(
"[par-term SECURITY BLOCK] Trigger '{trigger_name}' has `prompt_before_run: false` \
but is missing `i_accept_the_risk: true`.\n\
Dangerous actions (RunCommand/SendText/SplitPane) will NOT execute for this trigger \
until you add `i_accept_the_risk: true` to acknowledge the risk.\n\
This opt-in is required when bypassing the confirmation dialog for dangerous actions.\n\
Recommendation: set `prompt_before_run: true` (the default) instead."
);
}
}
pub struct TriggerRateLimiter {
last_fire: std::collections::HashMap<u64, std::time::Instant>,
min_interval_ms: u64,
}
const DEFAULT_TRIGGER_RATE_LIMIT_MS: u64 = 1000;
impl Default for TriggerRateLimiter {
fn default() -> Self {
Self {
last_fire: std::collections::HashMap::new(),
min_interval_ms: DEFAULT_TRIGGER_RATE_LIMIT_MS,
}
}
}
impl TriggerRateLimiter {
pub fn new(min_interval_ms: u64) -> Self {
Self {
last_fire: std::collections::HashMap::new(),
min_interval_ms,
}
}
pub fn check_and_update(&mut self, trigger_id: u64) -> bool {
let now = std::time::Instant::now();
if let Some(last) = self.last_fire.get(&trigger_id) {
let elapsed = now.duration_since(*last).as_millis() as u64;
if elapsed < self.min_interval_ms {
return false;
}
}
self.last_fire.insert(trigger_id, now);
true
}
pub fn cleanup(&mut self, max_age_secs: u64) {
let now = std::time::Instant::now();
let max_age = std::time::Duration::from_secs(max_age_secs);
self.last_fire
.retain(|_, last| now.duration_since(*last) < max_age);
}
}
#[cfg(test)]
mod split_pane_tests {
use super::*;
#[test]
fn test_split_pane_config_deserialize_send_text() {
let yaml = r#"
type: split_pane
direction: horizontal
command:
type: send_text
text: "tail -f build.log"
delay_ms: 300
focus_new_pane: true
target: active
"#;
let action: TriggerActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(matches!(action, TriggerActionConfig::SplitPane { .. }));
assert!(action.is_dangerous());
}
#[test]
fn test_split_pane_config_deserialize_initial_command() {
let yaml = r#"
type: split_pane
direction: vertical
command:
type: initial_command
command: htop
args: []
focus_new_pane: false
target: source
"#;
let action: TriggerActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(matches!(
action,
TriggerActionConfig::SplitPane {
direction: TriggerSplitDirection::Vertical,
focus_new_pane: false,
target: TriggerSplitTarget::Source,
..
}
));
}
#[test]
fn test_split_pane_defaults() {
let yaml = r#"
type: split_pane
direction: horizontal
"#;
let action: TriggerActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
if let TriggerActionConfig::SplitPane {
command,
focus_new_pane,
target,
split_percent,
..
} = action
{
assert!(command.is_none());
assert!(focus_new_pane); assert_eq!(target, TriggerSplitTarget::Active); assert_eq!(split_percent, 66); } else {
panic!("wrong variant");
}
}
#[test]
fn test_send_text_default_delay() {
let yaml = r#"type: send_text
text: "hello"
"#;
let cmd: SplitPaneCommand = serde_yaml_ng::from_str(yaml).unwrap();
if let SplitPaneCommand::SendText { delay_ms, .. } = cmd {
assert_eq!(delay_ms, 200);
}
}
}