use crate::{config::TtsrSettings, instructions::InstructionFile, output::redact_sensitive_text};
use regex::Regex;
const STREAM_BUFFER_CHAR_LIMIT: usize = 8_192;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TtsrRuleSource {
Builtin,
Settings,
AgentsCurated,
}
impl TtsrRuleSource {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::Settings => "settings",
Self::AgentsCurated => "agents_curated",
}
}
}
#[derive(Debug, Clone)]
pub(super) struct TtsrRule {
pub(super) source: TtsrRuleSource,
pub(super) pattern: String,
regex: Regex,
pub(super) reminder: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct TtsrMatch {
pub(super) rule_index: usize,
pub(super) source: TtsrRuleSource,
pub(super) pattern: String,
pub(super) reminder: String,
pub(super) matched_text_redacted: String,
}
#[derive(Debug, Clone)]
pub(crate) struct TtsrInterrupted {
pub(super) ttsr_match: TtsrMatch,
}
impl TtsrInterrupted {
#[cfg(test)]
pub(crate) fn test_instance() -> Self {
Self {
ttsr_match: TtsrMatch {
rule_index: 0,
source: TtsrRuleSource::Builtin,
pattern: "test".to_string(),
reminder: "test reminder".to_string(),
matched_text_redacted: "test".to_string(),
},
}
}
}
impl std::fmt::Display for TtsrInterrupted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("provider stream interrupted by TTSR rule")
}
}
impl std::error::Error for TtsrInterrupted {}
#[derive(Debug, Clone)]
pub(super) struct TtsrRuleSet {
rules: Vec<TtsrRule>,
stream_buffer: String,
}
impl TtsrRuleSet {
pub(super) fn new(
settings: &TtsrSettings,
instructions: &[InstructionFile],
) -> anyhow::Result<Option<Self>> {
if !settings.enabled {
return Ok(None);
}
let mut rules = builtin_rules()?;
rules.extend(
settings
.rules
.iter()
.map(|rule| {
Ok(TtsrRule {
source: TtsrRuleSource::Settings,
pattern: rule.pattern.clone(),
regex: Regex::new(&rule.pattern)?,
reminder: rule.reminder.clone(),
})
})
.collect::<anyhow::Result<Vec<_>>>()?,
);
rules.extend(curated_agents_rules(instructions)?);
Ok(Some(Self {
rules,
stream_buffer: String::new(),
}))
}
pub(super) fn reset_stream_buffer(&mut self) {
self.stream_buffer.clear();
}
pub(super) fn check_text(&mut self, candidate: &str) -> Option<TtsrMatch> {
self.stream_buffer.push_str(candidate);
truncate_to_last_chars(&mut self.stream_buffer, STREAM_BUFFER_CHAR_LIMIT);
self.check_buffer()
}
pub(super) fn check_standalone(&self, candidate: &str) -> Option<TtsrMatch> {
self.rules.iter().enumerate().find_map(|(index, rule)| {
rule.regex.find(candidate).map(|matched| TtsrMatch {
rule_index: index,
source: rule.source,
pattern: rule.pattern.clone(),
reminder: rule.reminder.clone(),
matched_text_redacted: redact_sensitive_text(matched.as_str()),
})
})
}
fn check_buffer(&self) -> Option<TtsrMatch> {
self.rules.iter().enumerate().find_map(|(index, rule)| {
rule.regex
.find(&self.stream_buffer)
.map(|matched| TtsrMatch {
rule_index: index,
source: rule.source,
pattern: rule.pattern.clone(),
reminder: rule.reminder.clone(),
matched_text_redacted: redact_sensitive_text(matched.as_str()),
})
})
}
#[cfg(test)]
pub(super) fn rules(&self) -> &[TtsrRule] {
&self.rules
}
}
fn truncate_to_last_chars(text: &mut String, limit: usize) {
let char_count = text.chars().count();
if char_count <= limit {
return;
}
let remove_chars = char_count - limit;
if let Some((byte_index, _)) = text.char_indices().nth(remove_chars) {
text.drain(..byte_index);
}
}
fn builtin_rules() -> anyhow::Result<Vec<TtsrRule>> {
[
(
TtsrRuleSource::Builtin,
r"(?i)\b(rm\s+-rf\s+/(?:\s|$)|sudo\s+rm\s+-rf|trash\s+/(?:\s|$)|trash\s+~(?:/)?(?:\s|$)|trash\s+/\*)",
"Safety reminder: do not emit destructive filesystem commands targeting root, home, or broad paths. Use non-destructive alternatives and ask for explicit confirmation when destructive action is required.",
),
(
TtsrRuleSource::Builtin,
r#"(?i)\b(?:api[_-]?key|secret|token|password|refresh[_-]?token|bearer)\b\s*[:=]\s*['\"]?[A-Za-z0-9_\-\.]{12,}"#,
"Security reminder: do not expose credentials or secret values. Redact secrets as [REDACTED] and describe safe handling steps instead.",
),
(
TtsrRuleSource::Builtin,
r"(?i)\b(?:OPENAI_API_KEY|MC_API_KEY|--api-key)\b.*\b(?:openai-codex|anthropic|claude-code)\b|\b(?:openai-codex|anthropic|claude-code)\b.*\b(?:OPENAI_API_KEY|MC_API_KEY|--api-key)\b",
"Credential routing reminder: do not route OPENAI_API_KEY, MC_API_KEY, or --api-key into openai-codex, anthropic, or claude-code providers.",
),
(
TtsrRuleSource::Builtin,
r"(?i)\b(?:widen|bypass|disable|ignore)\b.{0,80}\b(?:cwd|root|sandbox|path access|root access)\b|\b(?:cwd|root|sandbox|path access|root access)\b.{0,80}\b(?:widen|bypass|disable|ignore)\b",
"Safety boundary reminder: do not widen cwd/root access or bypass sandbox/path validation. Keep access scoped to the configured workspace.",
),
(
TtsrRuleSource::Builtin,
r"(?i)\bgit\s+push\b[^\n]*(?:--force|-f)|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-fd",
"Git safety reminder: do not emit destructive git commands such as force-push, hard reset, or clean without explicit user instruction and safeguards.",
),
]
.into_iter()
.map(|(source, pattern, reminder)| Ok(TtsrRule {
source,
pattern: pattern.to_string(),
regex: Regex::new(pattern)?,
reminder: reminder.to_string(),
}))
.collect()
}
fn curated_agents_rules(instructions: &[InstructionFile]) -> anyhow::Result<Vec<TtsrRule>> {
let combined = instructions
.iter()
.map(|file| file.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let lower = combined.to_ascii_lowercase();
if lower.trim().is_empty() {
return Ok(Vec::new());
}
let wanted = [
lower.contains("rm") || lower.contains("destructive"),
lower.contains("secret") || lower.contains("credential") || lower.contains("token"),
lower.contains("openai_api_key")
|| lower.contains("mc_api_key")
|| lower.contains("--api-key"),
lower.contains("cwd") || lower.contains("root access") || lower.contains("sandbox"),
lower.contains("force-push")
|| lower.contains("reset --hard")
|| lower.contains("git clean"),
];
let defaults = builtin_rules()?;
Ok(defaults
.into_iter()
.zip(wanted)
.filter_map(|(mut rule, include)| {
include.then(|| {
rule.source = TtsrRuleSource::AgentsCurated;
rule
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TtsrSettings;
fn enabled_ttsr_settings() -> TtsrSettings {
TtsrSettings {
enabled: true,
rules: Vec::new(),
}
}
#[test]
fn ttsr_builtin_defaults_include_destructive_secret_credential_cwd_and_git_rules() {
let rules = TtsrRuleSet::new(&enabled_ttsr_settings(), &[])
.unwrap()
.unwrap();
assert_eq!(rules.rules().len(), 5);
for sample in vec![
"rm -rf / ".to_string(),
format!("secret = {}", "x".repeat(16)),
"OPENAI_API_KEY into openai-codex".to_string(),
"bypass sandbox path access".to_string(),
"git push origin main --force".to_string(),
] {
assert!(
rules.clone().check_text(&sample).is_some(),
"sample missed: {sample}"
);
}
}
#[test]
fn ttsr_destructive_rule_targets_root_home_glob_only_not_scoped_paths() {
let rules = TtsrRuleSet::new(&enabled_ttsr_settings(), &[])
.unwrap()
.unwrap();
for sample in [
"trash / ",
"trash ~/ ",
"trash ~",
"trash /*",
"rm -rf / ",
"sudo rm -rf",
] {
assert!(
rules.clone().check_text(sample).is_some(),
"dangerous target should match: {sample}"
);
}
for sample in [
"trash /Users/magimetal/Dev/magi-code/.smoke_test_tmp.txt",
"trash /Users/magimetal/Dev/magi-code/state/smoke_test_tmp.txt && echo \"cleanup OK\"",
"trash /tmp/specific-file",
"trash ~/file.txt",
"trash ~/.cache/file",
"trash ~/Dev/project/file.txt",
] {
assert!(
rules.clone().check_text(sample).is_none(),
"scoped cleanup should not match: {sample}"
);
}
}
}