Skip to main content

vellaveto_engine/
cumulative_harm.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! STAC (Sequences of Tool-Chaining Attacks) cumulative harm scoring.
9//!
10//! Individual tool calls may each be benign, but their composition can be
11//! harmful. This module scores cumulative harm from tool call sequences
12//! that no individual-call analysis would flag.
13//!
14//! Based on: "Sequences of Tool-Chaining Attacks" (COLING 2025)
15//!
16//! Key insight: function calling creates a parallel path around safety
17//! alignment. Tool descriptions serve as a privileged instruction channel.
18//! Safety must consider cumulative impact, not individual actions.
19
20use vellaveto_types::provenance::SinkClass;
21
22/// Maximum chain entries to track.
23const MAX_CHAIN_LEN: usize = 100;
24
25/// A step in a tool chain.
26#[derive(Debug, Clone)]
27struct ChainStep {
28    tool_name: String,
29    sink_class: SinkClass,
30    reads_sensitive: bool,
31    #[allow(dead_code)]
32    writes_external: bool,
33    #[allow(dead_code)]
34    executes_code: bool,
35}
36
37/// Harmful chain patterns and their scores.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum HarmfulChainPattern {
40    /// Read sensitive data then send externally.
41    ReadThenExfiltrate,
42    /// Read credentials then use them in another tool.
43    CredentialHarvest,
44    /// Modify config then execute code (persistence).
45    ConfigThenExecute,
46    /// Enumerate system then exploit findings.
47    ReconThenExploit,
48    /// Multiple privilege escalation steps.
49    PrivilegeChain,
50}
51
52impl HarmfulChainPattern {
53    pub fn severity(&self) -> u32 {
54        match self {
55            Self::ReadThenExfiltrate => 90,
56            Self::CredentialHarvest => 95,
57            Self::ConfigThenExecute => 85,
58            Self::ReconThenExploit => 80,
59            Self::PrivilegeChain => 75,
60        }
61    }
62}
63
64/// A detected harmful chain.
65#[derive(Debug, Clone)]
66pub struct HarmfulChainFinding {
67    pub pattern: HarmfulChainPattern,
68    pub severity: u32,
69    pub chain_length: usize,
70    pub description: String,
71}
72
73/// Tracks tool call chains and detects cumulative harm patterns.
74pub struct CumulativeHarmTracker {
75    chain: Vec<ChainStep>,
76    findings: Vec<HarmfulChainFinding>,
77}
78
79impl CumulativeHarmTracker {
80    pub fn new() -> Self {
81        Self {
82            chain: Vec::new(),
83            findings: Vec::new(),
84        }
85    }
86
87    /// Record a tool call and check for harmful chain patterns.
88    pub fn record_and_check(
89        &mut self,
90        tool_name: &str,
91        sink_class: SinkClass,
92        target_paths: &[String],
93        target_domains: &[String],
94    ) -> Vec<HarmfulChainFinding> {
95        let reads_sensitive = is_sensitive_read(tool_name, target_paths);
96        let writes_external =
97            !target_domains.is_empty() || matches!(sink_class, SinkClass::NetworkEgress);
98        let executes_code = matches!(sink_class, SinkClass::CodeExecution);
99
100        if self.chain.len() < MAX_CHAIN_LEN {
101            self.chain.push(ChainStep {
102                tool_name: tool_name[..tool_name.len().min(256)].to_string(),
103                sink_class,
104                reads_sensitive,
105                writes_external,
106                executes_code,
107            });
108        }
109
110        let mut new_findings = Vec::new();
111
112        // Check for ReadThenExfiltrate: read sensitive → send external
113        if writes_external {
114            for prev in self.chain.iter().rev().skip(1).take(5) {
115                if prev.reads_sensitive {
116                    let finding = HarmfulChainFinding {
117                        pattern: HarmfulChainPattern::ReadThenExfiltrate,
118                        severity: HarmfulChainPattern::ReadThenExfiltrate.severity(),
119                        chain_length: 2,
120                        description: format!(
121                            "'{}' (sensitive read) → '{}' (external write)",
122                            prev.tool_name, tool_name
123                        ),
124                    };
125                    new_findings.push(finding);
126                    break;
127                }
128            }
129        }
130
131        // Check for CredentialHarvest: read creds → use them
132        if !target_domains.is_empty() || writes_external {
133            for prev in self.chain.iter().rev().skip(1).take(3) {
134                if is_credential_read(&prev.tool_name, &[]) {
135                    let finding = HarmfulChainFinding {
136                        pattern: HarmfulChainPattern::CredentialHarvest,
137                        severity: HarmfulChainPattern::CredentialHarvest.severity(),
138                        chain_length: 2,
139                        description: format!(
140                            "'{}' (credential read) → '{}' (credential use)",
141                            prev.tool_name, tool_name
142                        ),
143                    };
144                    new_findings.push(finding);
145                    break;
146                }
147            }
148        }
149
150        // Check for ConfigThenExecute: write config → execute code
151        if executes_code {
152            for prev in self.chain.iter().rev().skip(1).take(5) {
153                if is_config_write(&prev.tool_name) {
154                    let finding = HarmfulChainFinding {
155                        pattern: HarmfulChainPattern::ConfigThenExecute,
156                        severity: HarmfulChainPattern::ConfigThenExecute.severity(),
157                        chain_length: 2,
158                        description: format!(
159                            "'{}' (config write) → '{}' (code execution)",
160                            prev.tool_name, tool_name
161                        ),
162                    };
163                    new_findings.push(finding);
164                    break;
165                }
166            }
167        }
168
169        // Check for PrivilegeChain: escalating sink classes
170        if self.chain.len() >= 3 {
171            let last_3: Vec<u8> = self
172                .chain
173                .iter()
174                .rev()
175                .take(3)
176                .map(|s| s.sink_class.rank())
177                .collect();
178            if last_3.len() == 3
179                && last_3[0] > last_3[1]
180                && last_3[1] > last_3[2]
181                && last_3[0] >= SinkClass::CodeExecution.rank()
182            {
183                new_findings.push(HarmfulChainFinding {
184                    pattern: HarmfulChainPattern::PrivilegeChain,
185                    severity: HarmfulChainPattern::PrivilegeChain.severity(),
186                    chain_length: 3,
187                    description: "escalating sink class chain detected".to_string(),
188                });
189            }
190        }
191
192        self.findings.extend(new_findings.clone());
193        new_findings
194    }
195
196    /// Get the maximum severity across all findings.
197    pub fn max_severity(&self) -> u32 {
198        self.findings.iter().map(|f| f.severity).max().unwrap_or(0)
199    }
200
201    /// Get all findings.
202    pub fn findings(&self) -> &[HarmfulChainFinding] {
203        &self.findings
204    }
205}
206
207impl Default for CumulativeHarmTracker {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213fn is_sensitive_read(tool_name: &str, paths: &[String]) -> bool {
214    let sensitive_patterns = [
215        ".aws",
216        ".ssh",
217        ".env",
218        "credentials",
219        "secret",
220        "passwd",
221        "shadow",
222    ];
223    paths
224        .iter()
225        .any(|p| sensitive_patterns.iter().any(|s| p.contains(s)))
226        || tool_name.contains("credential")
227        || tool_name.contains("secret")
228}
229
230fn is_credential_read(tool_name: &str, _paths: &[String]) -> bool {
231    tool_name.contains("credential")
232        || tool_name.contains("secret")
233        || tool_name.contains("password")
234        || tool_name.contains("token")
235        || tool_name.contains("key")
236}
237
238fn is_config_write(tool_name: &str) -> bool {
239    (tool_name.contains("write") || tool_name.contains("modify") || tool_name.contains("set"))
240        && (tool_name.contains("config")
241            || tool_name.contains("setting")
242            || tool_name.contains("hook"))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_read_then_exfiltrate() {
251        let mut tracker = CumulativeHarmTracker::new();
252        tracker.record_and_check(
253            "read_file",
254            SinkClass::ReadOnly,
255            &["/home/user/.aws/credentials".to_string()],
256            &[],
257        );
258        let findings = tracker.record_and_check(
259            "http_post",
260            SinkClass::NetworkEgress,
261            &[],
262            &["evil.com".to_string()],
263        );
264        assert!(findings
265            .iter()
266            .any(|f| f.pattern == HarmfulChainPattern::ReadThenExfiltrate));
267    }
268
269    #[test]
270    fn test_credential_harvest() {
271        let mut tracker = CumulativeHarmTracker::new();
272        tracker.record_and_check("get_secret_key", SinkClass::ReadOnly, &[], &[]);
273        let findings = tracker.record_and_check(
274            "http_request",
275            SinkClass::NetworkEgress,
276            &[],
277            &["api.target.com".to_string()],
278        );
279        assert!(findings
280            .iter()
281            .any(|f| f.pattern == HarmfulChainPattern::CredentialHarvest));
282    }
283
284    #[test]
285    fn test_config_then_execute() {
286        let mut tracker = CumulativeHarmTracker::new();
287        tracker.record_and_check("write_config", SinkClass::FilesystemWrite, &[], &[]);
288        let findings =
289            tracker.record_and_check("execute_command", SinkClass::CodeExecution, &[], &[]);
290        assert!(findings
291            .iter()
292            .any(|f| f.pattern == HarmfulChainPattern::ConfigThenExecute));
293    }
294
295    #[test]
296    fn test_privilege_chain() {
297        let mut tracker = CumulativeHarmTracker::new();
298        tracker.record_and_check("list_files", SinkClass::ReadOnly, &[], &[]);
299        tracker.record_and_check("write_file", SinkClass::FilesystemWrite, &[], &[]);
300        let findings = tracker.record_and_check("execute_cmd", SinkClass::CodeExecution, &[], &[]);
301        assert!(findings
302            .iter()
303            .any(|f| f.pattern == HarmfulChainPattern::PrivilegeChain));
304    }
305
306    #[test]
307    fn test_benign_chain_no_findings() {
308        let mut tracker = CumulativeHarmTracker::new();
309        tracker.record_and_check(
310            "read_file",
311            SinkClass::ReadOnly,
312            &["/tmp/readme.md".to_string()],
313            &[],
314        );
315        tracker.record_and_check(
316            "read_file",
317            SinkClass::ReadOnly,
318            &["/tmp/notes.txt".to_string()],
319            &[],
320        );
321        let findings = tracker.record_and_check(
322            "write_file",
323            SinkClass::FilesystemWrite,
324            &["/tmp/output.txt".to_string()],
325            &[],
326        );
327        assert!(
328            findings.is_empty(),
329            "Benign read→read→write should not flag"
330        );
331    }
332
333    #[test]
334    fn test_max_severity() {
335        let mut tracker = CumulativeHarmTracker::new();
336        tracker.record_and_check("get_secret_key", SinkClass::ReadOnly, &[], &[]);
337        tracker.record_and_check(
338            "http_post",
339            SinkClass::NetworkEgress,
340            &[],
341            &["evil.com".to_string()],
342        );
343        assert!(tracker.max_severity() >= 90);
344    }
345}