Root cause
Other / insufficient public mechanism detail
feat: TOML Part 1 — filter DSL engine + 14 built-in filters (#349)
How AI contributed: Flawed AI-written codePublished May 20, 2026 in
rtk-ai/rtk
Root cause
feat: TOML Part 1 — filter DSL engine + 14 built-in filters (#349)
Fix
security: trust boundary for project-local TOML filters (SA-2025-RTK-002) (#623)
Case facts
@@ -9,7 +9,7 @@ use anyhow::{Context, Result}; use regex::Regex; use std::process::Command; -/// Truncates a string to `max_len` characters, appending "..." if needed.+/// Truncates a string to `max_len` characters, appending `...` if needed. /// /// # Arguments /// * `s` - The string to truncate@@ -36,7 +36,7 @@ pub fn truncate(s: &str, max_len: usize) -> String { /// Strips ANSI escape codes (colors, styles) from a string. /// /// # Arguments-/// * `text` - Text potentially containing ANSI codes+/// * `text` - Text potentially containing ANSI escape codes /// /// # Examples /// ```@@ -51,7 +51,7 @@ pub fn strip_ansi(text: &str) -> String { ANSI_RE.replace_all(text, "").to_string() } -/// Execute a command and return cleaned stdout/stderr.+/// Executes a command and returns cleaned stdout/stderr. /// /// # Arguments /// * `cmd` - Command to execute (e.g. "eslint")@@ -80,7 +80,7 @@ pub fn execute_command(cmd: &str, args: &[&str]) -> Result<(String, String, i32) Ok((stdout, stderr, exit_code)) } -/// Format a token count with K/M suffixes for readability.+/// Formats a token count with K/M suffixes for readability. /// /// # Arguments /// * `n` - Token count@@ -105,7 +105,7 @@ pub fn format_tokens(n: usize) -> String { } } -/// Format a USD amount with adaptive precision.+/// Formats a USD amount with adaptive precision. /// /// # Arguments /// * `amount` - Amount in dollars@@ -0,0 +1,1305 @@+/// TOML-based filter DSL for RTK.+///+/// Provides a declarative pipeline of 8 stages that can be configured+/// via `.rtk/filters.toml` (project-local) or built-in TOML embedded in the binary.+///+/// Lookup priority (first match wins):+/// 1. `.rtk/filters.toml` (PWD — project-local, committable)+/// 2. Built-in TOML (`src/builtin_filters.toml`, embedded at compile time)+/// 3. Passthrough (no match — handled by caller)+///+/// Environment variables:+/// - `RTK_NO_TOML=1` — bypass TOML engine entirely+/// - `RTK_TOML_DEBUG=1` — print which filter matched and line counts to stderr+///+/// Pipeline stages (applied in order):+/// 1. strip_ansi — remove ANSI escape codes+/// 2. replace — regex substitutions, line-by-line, chainable+/// 3. match_output — short-circuit: if blob matches a pattern, return message immediately+/// 4. strip/keep_lines — filter lines by regex+/// 5. truncate_lines_at — truncate each line to N chars+/// 6. head/tail_lines — keep first/last N lines+/// 7. max_lines — absolute line cap+/// 8. on_empty — message if result is empty+use lazy_static::lazy_static;+use regex::{Regex, RegexSet};+use serde::Deserialize;+use std::collections::BTreeMap;++// ---------------------------------------------------------------------------+// Deserialization types (TOML schema)+// ---------------------------------------------------------------------------++/// A match-output rule: if `pattern` matches anywhere in the full output blob,+/// the filter short-circuits and returns `message` immediately.+/// First matching rule wins; remaining rules are not evaluated.+#[derive(Deserialize)]+#[serde(deny_unknown_fields)]+struct MatchOutputRule {+ pattern: String,+ message: String,+}++/// A regex substitution applied line-by-line. Rules are chained sequentially:+/// rule N+1 operates on the output of rule N.+/// Backreferences (`$1`, `$2`, ...) are supported via the `regex` crate.+#[derive(Deserialize)]+#[serde(deny_unknown_fields)]+struct ReplaceRule {+ pattern: String,+ replacement: String,+}++/// An inline test case attached to a filter in the TOML.+/// Lives in `[[tests.<filter-name>]]` sections, separate from `[filters.*]`.+#[derive(Deserialize)]+#[serde(deny_unknown_fields)]+pub struct TomlFilterTestDef {+ pub name: String,+ pub input: String,+ pub expected: String,+}++#[derive(Deserialize)]+struct TomlFilterFile {+ schema_version: u32,+ #[serde(default)]+ filters: BTreeMap<String, TomlFilterDef>,+ /// Inline tests keyed by filter name. Kept separate from `filters` so that+ /// `TomlFilterDef` can keep `deny_unknown_fields` without touching test data.+ #[serde(default)]+ tests: BTreeMap<String, Vec<TomlFilterTestDef>>,+}++#[derive(Deserialize)]+#[serde(deny_unknown_fields)]+struct TomlFilterDef {+ description: Option<String>,+ match_command: String,+ #[serde(default)]+ strip_ansi: bool,+ /// Regex substitutions, applied line-by-line before match_output (stage 2).+ #[serde(default)]+ replace: Vec<ReplaceRule>,+ /// Short-circuit rules: if the full output blob matches, return the message (stage 3).+ #[serde(default)]+ match_output: Vec<MatchOutputRule>,+ #[serde(default)]+ strip_lines_matching: Vec<String>,+ #[serde(default)]@@ -0,0 +1,47 @@+use anyhow::Result;++use crate::toml_filter;++/// Run TOML filter inline tests.+///+/// - `filter`: if `Some`, only run tests for that filter name+/// - `require_all`: fail if any filter has no inline tests+pub fn run(filter: Option<String>, require_all: bool) -> Result<()> {+ let results = toml_filter::run_filter_tests(filter.as_deref());++ let total = results.outcomes.len();+ let passed = results.outcomes.iter().filter(|o| o.passed).count();+ let failed = total - passed;++ // Print failures with details+ for outcome in &results.outcomes {+ if !outcome.passed {+ eprintln!(+ "FAIL [{}] {}\n expected: {:?}\n actual: {:?}",+ outcome.filter_name, outcome.test_name, outcome.expected, outcome.actual+ );+ }+ }++ if total == 0 {+ println!("No inline tests found.");+ } else {+ println!("{}/{} tests passed", passed, total);+ }++ if require_all && !results.filters_without_tests.is_empty() {+ for name in &results.filters_without_tests {+ eprintln!("MISSING tests for filter: {}", name);+ }+ anyhow::bail!(+ "{} filter(s) have no inline tests (use --require-all in CI)",+ results.filters_without_tests.len()+ );+ }++ if failed > 0 {+ anyhow::bail!("{} test(s) failed", failed);+ }++ Ok(())+}@@ -0,0 +1,510 @@+//! Trust boundary for project-local TOML filters (SA-2025-RTK-002).+//!+//! `.rtk/filters.toml` is loaded from CWD with highest priority. An attacker+//! can commit this file to a public repo to control what an LLM sees — hiding+//! malicious code, suppressing security scanner output, or rewriting command+//! output entirely via `replace` and `match_output` primitives.+//!+//! This module implements a trust-before-load model:+//! - Untrusted filters are **skipped** (not "loaded with warning")+//! - `rtk trust` stores the SHA-256 hash after user review+//! - Content changes invalidate trust (re-review required)+//! - `RTK_TRUST_PROJECT_FILTERS=1` overrides for CI pipelines++use crate::integrity;+use anyhow::{Context, Result};+use serde::{Deserialize, Serialize};+use std::collections::HashMap;+use std::path::{Path, PathBuf};++// ---------------------------------------------------------------------------+// Types+// ---------------------------------------------------------------------------++#[derive(Serialize, Deserialize, Default)]+struct TrustStore {+ version: u32,+ trusted: HashMap<String, TrustEntry>,+}++#[derive(Serialize, Deserialize, Clone)]+pub struct TrustEntry {+ pub sha256: String,+ pub trusted_at: String,+}++#[derive(Debug, PartialEq)]+pub enum TrustStatus {+ Trusted,+ Untrusted,+ ContentChanged { expected: String, actual: String },+ EnvOverride,+}++// ---------------------------------------------------------------------------+// Store path+// ---------------------------------------------------------------------------++fn store_path() -> Result<PathBuf> {+ let data_dir = dirs::data_local_dir().context("Cannot determine local data directory")?;+ Ok(data_dir.join("rtk").join("trusted_filters.json"))+}++fn read_store() -> Result<TrustStore> {+ let path = store_path()?;+ if !path.exists() {+ return Ok(TrustStore::default());+ }+ let content = std::fs::read_to_string(&path)+ .with_context(|| format!("Failed to read trust store: {}", path.display()))?;+ serde_json::from_str(&content)+ .with_context(|| format!("Failed to parse trust store: {}", path.display()))+}++fn write_store(store: &TrustStore) -> Result<()> {+ let path = store_path()?;+ if let Some(parent) = path.parent() {+ std::fs::create_dir_all(parent)+ .with_context(|| format!("Failed to create directory: {}", parent.display()))?;+ }+ let content = serde_json::to_string_pretty(store).context("Failed to serialize trust store")?;+ std::fs::write(&path, content)+ .with_context(|| format!("Failed to write trust store: {}", path.display()))+}++// ---------------------------------------------------------------------------+// Canonical path helper+// ---------------------------------------------------------------------------++fn canonical_key(filter_path: &Path) -> Result<String> {+ // Resolve symlinks and produce an absolute path. No fallback — if we can't+ // canonicalize, we can't safely key the trust entry (fail-closed).+ let canonical = std::fs::canonicalize(filter_path)+ .with_context(|| format!("Cannot resolve path: {}", filter_path.display()))?;+ Ok(canonical.to_string_lossy().to_string())+}++// ---------------------------------------------------------------------------+// Public API+// ---------------------------------------------------------------------------@@ -181,11 +181,29 @@ impl TomlFilterRegistry { fn load() -> Self { let mut filters = Vec::new(); - // Priority 1: project-local .rtk/filters.toml- if let Ok(content) = std::fs::read_to_string(".rtk/filters.toml") {- match Self::parse_and_compile(&content, "project") {- Ok(f) => filters.extend(f),- Err(e) => eprintln!("[rtk] warning: .rtk/filters.toml: {}", e),+ // Priority 1: project-local .rtk/filters.toml (trust-gated)+ let project_filter_path = std::path::Path::new(".rtk/filters.toml");+ if project_filter_path.exists() {+ let trust_status = crate::trust::check_trust(project_filter_path)+ .unwrap_or(crate::trust::TrustStatus::Untrusted);++ match trust_status {+ crate::trust::TrustStatus::Trusted | crate::trust::TrustStatus::EnvOverride => {+ if let Ok(content) = std::fs::read_to_string(project_filter_path) {+ match Self::parse_and_compile(&content, "project") {+ Ok(f) => filters.extend(f),+ Err(e) => eprintln!("[rtk] warning: .rtk/filters.toml: {}", e),+ }+ }+ }+ crate::trust::TrustStatus::Untrusted => {+ eprintln!("[rtk] WARNING: untrusted project filters (.rtk/filters.toml)");+ eprintln!("[rtk] Filters NOT applied. Run `rtk trust` to review and enable.");+ }+ crate::trust::TrustStatus::ContentChanged { .. } => {+ eprintln!("[rtk] WARNING: .rtk/filters.toml changed since trusted.");+ eprintln!("[rtk] Filters NOT applied. Run `rtk trust` to re-review.");+ } } } @@ -529,14 +547,27 @@ pub fn run_filter_tests(filter_name_opt: Option<&str>) -> VerifyResults { &mut tested_filter_names, ); - if let Ok(content) = std::fs::read_to_string(".rtk/filters.toml") {- collect_test_outcomes(- &content,- filter_name_opt,- &mut outcomes,- &mut all_filter_names,- &mut tested_filter_names,- );+ // Trust-gated: only verify project-local filters if trusted (SA-2025-RTK-002)+ let project_path = std::path::Path::new(".rtk/filters.toml");+ if project_path.exists() {+ let trust_status =+ crate::trust::check_trust(project_path).unwrap_or(crate::trust::TrustStatus::Untrusted);+ match trust_status {+ crate::trust::TrustStatus::Trusted | crate::trust::TrustStatus::EnvOverride => {+ if let Ok(content) = std::fs::read_to_string(project_path) {+ collect_test_outcomes(+ &content,+ filter_name_opt,+ &mut outcomes,+ &mut all_filter_names,+ &mut tested_filter_names,+ );+ }+ }+ _ => {+ eprintln!("[rtk] WARNING: untrusted project filters skipped in verify");+ }+ } } let filters_without_tests = all_filter_names@@ -56,6 +56,7 @@ mod telemetry; mod toml_filter; mod tracking; mod tree;+mod trust; mod tsc_cmd; mod utils; mod verify_cmd;@@ -573,6 +574,16 @@ enum Commands { args: Vec<OsString>, }, + /// Trust project-local TOML filters in current directory+ Trust {+ /// List all trusted projects+ #[arg(long)]+ list: bool,+ },++ /// Revoke trust for project-local TOML filters+ Untrust,+ /// Verify hook integrity and run TOML filter inline tests Verify { /// Run tests only for this filter name@@ -2088,6 +2099,14 @@ fn main() -> Result<()> { } } + Commands::Trust { list } => {+ trust::run_trust(list)?;+ }++ Commands::Untrust => {+ trust::run_untrust()?;+ }+ Commands::Verify { filter, require_all,AI-assisted change 3bf147b6966ccb2a0f4a885044f3ccf6fdd031c16b3c12432584ddd2f74d525b · Fix 0796520f962eb6f42319235025ba1d2b82d58ed9beeef28bcbca05c8afc7801b