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}