Skip to main content

lc_core/tools/
risk.rs

1// src/core/tools/risk.rs
2//! Risk profile for Rule-of-Two tool approval (A2, v0.22.1).
3//!
4//! A tool author declares three orthogonal risk properties when implementing a tool.
5//! The agent checks these **before executing** a tool call: a call that arms all three
6//! properties at once (`count_armed() == 3`) is a complete "untrusted input -> sensitive
7//! access -> state change" chain and is refused (see lc-agents' RuleOfTwoCheck).
8//!
9//! Design note: this lives on the *execution side* (`BaseTool`), NOT in `ToolDefinition`.
10//! `ToolDefinition` is serialized into the request body sent to the model provider, so a
11//! risk field there would leak into the wire format. `BaseTool` is the framework-internal
12//! registry object, so declaring risk here keeps it out of the model request entirely.
13
14/// Declared risk properties of a tool, used by the Rule-of-Two check (A2).
15///
16/// All three default to `false`, so an undeclared tool is fully low-risk and provokes no
17/// interception — behaviour is byte-for-byte identical to a framework without this rule.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct ToolRiskProfile {
20    /// The tool's input originates from an untrusted external source
21    /// (web page, retrieved document, raw user text).
22    pub untrusted_input: bool,
23    /// The tool can touch sensitive resources (read files, open network connections).
24    pub sensitive_access: bool,
25    /// Calling the tool changes external world state (write DB, send mail, delete, pay).
26    pub state_changing: bool,
27}
28
29impl ToolRiskProfile {
30    /// An all-false profile: no declared risk, never intercepted.
31    pub const fn empty() -> Self {
32        Self {
33            untrusted_input: false,
34            sensitive_access: false,
35            state_changing: false,
36        }
37    }
38
39    /// Number of declared (true) properties, in `[0, 3]`.
40    ///
41    /// The Rule of Two fires when this reaches `3`: `untrusted_input + sensitive_access +
42    /// state_changing` all present forms a complete actionable-harm chain.
43    pub fn count_armed(&self) -> usize {
44        usize::from(self.untrusted_input)
45            + usize::from(self.sensitive_access)
46            + usize::from(self.state_changing)
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn empty_profile_arms_nothing() {
56        let p = ToolRiskProfile::empty();
57        assert_eq!(p, ToolRiskProfile::default());
58        assert_eq!(p.count_armed(), 0);
59    }
60
61    #[test]
62    fn count_armed_covers_all_eight_combinations() {
63        // All 3-bit combinations: expected armed count == number of set bits.
64        for bits in 0..8u8 {
65            let p = ToolRiskProfile {
66                untrusted_input: bits & 0b001 != 0,
67                sensitive_access: bits & 0b010 != 0,
68                state_changing: bits & 0b100 != 0,
69            };
70            assert_eq!(p.count_armed(), bits.count_ones() as usize);
71        }
72    }
73
74    #[test]
75    fn all_true_is_rule_of_two_trip() {
76        let p = ToolRiskProfile {
77            untrusted_input: true,
78            sensitive_access: true,
79            state_changing: true,
80        };
81        assert_eq!(p.count_armed(), 3);
82    }
83}