Skip to main content

telos_agent/tools/command_security/bash/
quote_context.rs

1//! Quote context extraction for bash commands.
2//!
3//! Provides three views of a command string, matching the semantics of
4//! telos-agent's `QuoteContext` in `utils/bash/treeSitterAnalysis.ts`:
5//!
6//! - `fully_unquoted`: all quoted content removed
7//! - `with_double_quotes`: single-quoted / ANSI-C / heredoc content removed,
8//!   double-quoted content preserved but delimiters stripped
9//! - `unquoted_keep_quote_chars`: quoted content removed, but quote delimiters kept
10//!
11//! These views are used to decide whether `$()`/`${}` / dangerous patterns are
12//! inside a context where they would actually expand at runtime.
13
14use super::parser::Node;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct QuoteContext {
18    /// Content with single-quoted / ANSI-C / heredoc content removed and
19    /// double-quote delimiters stripped (content preserved).
20    pub with_double_quotes: String,
21    /// Content with all quoted spans removed.
22    pub fully_unquoted: String,
23    /// Content with quoted spans removed, but delimiters (`'`, `"`, `$'`) kept.
24    pub unquoted_keep_quote_chars: String,
25}
26
27impl QuoteContext {
28    /// Extract quote context from a command AST.
29    pub fn from_node(root: &Node, source: &str) -> Self {
30        let spans = collect_quote_spans(root);
31
32        let single_quote_set: std::collections::HashSet<usize> = spans
33            .iter()
34            .filter(|s| s.kind != SpanKind::Double)
35            .flat_map(|s| s.start..s.end)
36            .collect();
37
38        let double_quote_delims: std::collections::HashSet<usize> = spans
39            .iter()
40            .filter(|s| s.kind == SpanKind::Double)
41            .flat_map(|s| [s.start, s.end - 1])
42            .collect();
43
44        let mut with_double_quotes = String::with_capacity(source.len());
45        for (i, c) in source.char_indices() {
46            if single_quote_set.contains(&i) {
47                continue;
48            }
49            if double_quote_delims.contains(&i) {
50                continue;
51            }
52            with_double_quotes.push(c);
53        }
54
55        let fully_unquoted = remove_spans(source, &spans);
56        let unquoted_keep_quote_chars = replace_spans_keep_quotes(source, &spans);
57
58        Self { with_double_quotes, fully_unquoted, unquoted_keep_quote_chars }
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum SpanKind {
64    Single,
65    Double,
66    AnsiC,
67    Heredoc,
68}
69
70#[derive(Debug, Clone, Copy)]
71struct Span {
72    start: usize,
73    end: usize,
74    kind: SpanKind,
75}
76
77fn collect_quote_spans(node: &Node) -> Vec<Span> {
78    let mut out = Vec::new();
79    collect_quote_spans_inner(node, &mut out, false);
80    out
81}
82
83fn collect_quote_spans_inner(node: &Node, out: &mut Vec<Span>, in_double: bool) {
84    match node.kind.as_str() {
85        "raw_string" => {
86            out.push(Span { start: node.start_byte, end: node.end_byte, kind: SpanKind::Single });
87            return;
88        }
89        "ansi_c_string" => {
90            out.push(Span { start: node.start_byte, end: node.end_byte, kind: SpanKind::AnsiC });
91            return;
92        }
93        "string" => {
94            if !in_double {
95                out.push(Span {
96                    start: node.start_byte,
97                    end: node.end_byte,
98                    kind: SpanKind::Double,
99                });
100            }
101            for child in &node.children {
102                collect_quote_spans_inner(child, out, true);
103            }
104            return;
105        }
106        "heredoc_redirect" if is_quoted_heredoc(node) => {
107            out.push(Span { start: node.start_byte, end: node.end_byte, kind: SpanKind::Heredoc });
108            return;
109        }
110        _ => {}
111    }
112
113    for child in &node.children {
114        collect_quote_spans_inner(child, out, in_double);
115    }
116}
117
118fn is_quoted_heredoc(node: &Node) -> bool {
119    node.children
120        .iter()
121        .find(|c| c.kind == "heredoc_start")
122        .map(|c| {
123            let first = c.text.chars().next().unwrap_or('\0');
124            first == '\'' || first == '"' || first == '\\'
125        })
126        .unwrap_or(false)
127}
128
129/// Drop spans fully contained within another span, keeping only the outermost.
130fn drop_contained_spans(spans: &mut Vec<Span>) {
131    let mut keep = vec![true; spans.len()];
132    for (i, s) in spans.iter().enumerate() {
133        for (j, other) in spans.iter().enumerate() {
134            if i == j {
135                continue;
136            }
137            if other.start <= s.start
138                && other.end >= s.end
139                && (other.start < s.start || other.end > s.end)
140            {
141                keep[i] = false;
142                break;
143            }
144        }
145    }
146    let mut i = 0;
147    spans.retain(|_| {
148        let k = keep[i];
149        i += 1;
150        k
151    });
152}
153
154fn remove_spans(source: &str, spans: &[Span]) -> String {
155    if spans.is_empty() {
156        return source.to_string();
157    }
158    let mut sorted: Vec<Span> = spans.to_vec();
159    drop_contained_spans(&mut sorted);
160    sorted.sort_by_key(|s| s.start);
161
162    let mut result = String::with_capacity(source.len());
163    let mut last_end = 0usize;
164    for span in sorted {
165        result.push_str(&source[last_end..span.start.min(source.len())]);
166        last_end = span.end.min(source.len());
167    }
168    result.push_str(&source[last_end..]);
169    result
170}
171
172fn replace_spans_keep_quotes(source: &str, spans: &[Span]) -> String {
173    if spans.is_empty() {
174        return source.to_string();
175    }
176    let sorted: Vec<(usize, usize, &'static str, &'static str)> = spans
177        .iter()
178        .map(|s| {
179            let (open, close) = match s.kind {
180                SpanKind::Single => ("'", "'"),
181                SpanKind::Double => ("\"", "\""),
182                SpanKind::AnsiC => ("$'", "'"),
183                SpanKind::Heredoc => ("", ""),
184            };
185            (s.start, s.end, open, close)
186        })
187        .collect();
188
189    // Drop contained spans
190    let len = sorted.len();
191    let mut keep = vec![true; len];
192    for i in 0..len {
193        for j in 0..len {
194            if i == j {
195                continue;
196            }
197            let (s_start, s_end, _, _) = sorted[i];
198            let (o_start, o_end, _, _) = sorted[j];
199            if o_start <= s_start && o_end >= s_end && (o_start < s_start || o_end > s_end) {
200                keep[i] = false;
201                break;
202            }
203        }
204    }
205    let mut filtered = Vec::new();
206    for i in 0..len {
207        if keep[i] {
208            filtered.push(sorted[i]);
209        }
210    }
211
212    filtered.sort_by_key(|s| s.0);
213
214    let mut result = String::with_capacity(source.len());
215    let mut last_end = 0usize;
216    for (start, end, open, close) in filtered {
217        result.push_str(&source[last_end..start.min(source.len())]);
218        result.push_str(open);
219        result.push_str(close);
220        last_end = end.min(source.len());
221    }
222    result.push_str(&source[last_end..]);
223    result
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::tools::command_security::bash::parser;
230
231    fn ctx(cmd: &str) -> QuoteContext {
232        let ast = parser::parse(cmd).unwrap();
233        QuoteContext::from_node(&ast, cmd)
234    }
235
236    #[test]
237    fn removes_single_quoted_content() {
238        let c = ctx("echo 'hello world'");
239        assert_eq!(c.fully_unquoted, "echo ");
240        assert_eq!(c.with_double_quotes, "echo ");
241        assert_eq!(c.unquoted_keep_quote_chars, "echo ''");
242    }
243
244    #[test]
245    fn keeps_double_quoted_content() {
246        let c = ctx(r#"echo "hello world""#);
247        assert_eq!(c.fully_unquoted, "echo ");
248        assert_eq!(c.with_double_quotes, "echo hello world");
249        assert_eq!(c.unquoted_keep_quote_chars, "echo \"\"");
250    }
251
252    #[test]
253    fn nested_quotes() {
254        let c = ctx(r#"echo "$(echo 'hi')""#);
255        // Outer double quotes removed, inner single-quoted content removed.
256        assert_eq!(c.with_double_quotes, "echo $(echo )");
257    }
258}