Skip to main content

aidens_security_kit/
lib.rs

1//! App-level path safety helpers and canonical tool side-effect classification.
2
3use llm_tool_runtime::ToolSideEffectClass;
4use serde::{Deserialize, Serialize};
5use std::path::{Component, Path, PathBuf};
6use thiserror::Error;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct McpTrustReportV1 {
10    pub tool_name: String,
11    pub side_effect_class: String,
12    pub requires_permit: bool,
13    pub is_dangerous: bool,
14    pub risk_label: String,
15    pub recommendation: String,
16}
17
18pub fn evaluate_mcp_tool_safety(descriptor: &llm_tool_runtime::ToolDescriptor) -> McpTrustReportV1 {
19    let side_effect_class = descriptor.side_effect_class.to_string();
20    let requires_permit = requires_explicit_permit(&descriptor.side_effect_class);
21    let is_dangerous = is_dangerous_without_permit(&descriptor.side_effect_class);
22
23    let risk_label = match descriptor.side_effect_class {
24        ToolSideEffectClass::ReadOnly => "low",
25        ToolSideEffectClass::Analysis | ToolSideEffectClass::PreviewWrite => "medium",
26        ToolSideEffectClass::Write => "high",
27        ToolSideEffectClass::Admin => "critical",
28    };
29
30    let recommendation = match risk_label {
31        "low" => "allow",
32        "medium" | "high" => "permit-required",
33        "critical" => "deny-by-default",
34        _ => "allow",
35    };
36
37    McpTrustReportV1 {
38        tool_name: descriptor.name.clone(),
39        side_effect_class,
40        requires_permit,
41        is_dangerous,
42        risk_label: risk_label.to_string(),
43        recommendation: recommendation.to_string(),
44    }
45}
46
47pub fn attest_tool_invocation(
48    receipt: &llm_tool_runtime::ToolReceipt,
49) -> attestation_exchange::AttestationEnvelopeV1 {
50    let attestation_envelope_id = receipt
51        .attestation_envelope_id
52        .clone()
53        .unwrap_or_else(|| format!("attn_{}", receipt.receipt_id).into());
54
55    let trace_context = receipt.trace_ctx.trace_id.clone();
56    let signing_time = if !receipt.finished_at.is_empty() {
57        receipt.finished_at.clone()
58    } else {
59        receipt.started_at.clone()
60    };
61    let trust_root_set_id = if trace_context.is_empty() {
62        "trs_unknown".to_string()
63    } else {
64        format!("trs_{}", &trace_context)
65    };
66    let disclosure_policy_id = if trace_context.is_empty() {
67        "dp_unknown".to_string()
68    } else {
69        format!("dp_{}", &trace_context)
70    };
71    let signer_identity = if trace_context.is_empty() {
72        receipt.tool_name.clone()
73    } else {
74        trace_context.clone()
75    };
76    let provenance_summary = if trace_context.is_empty() {
77        format!(
78            "invocation {} with attempt {}",
79            receipt.tool_name, receipt.attempt_id
80        )
81    } else {
82        format!(
83            "invocation {} with trace {} and attempt {}",
84            receipt.tool_name, trace_context, receipt.attempt_id,
85        )
86    };
87
88    if let Ok(envelope) = attestation_exchange::AttestationEnvelopeV1::new(
89        attestation_envelope_id.clone(),
90        "mcp-tool-invocation",
91        "v1",
92        receipt.input_digest.clone(),
93        "mcp-tool",
94        signer_identity.clone(),
95        signing_time.clone(),
96        trust_root_set_id.clone().into(),
97        provenance_summary.clone(),
98        disclosure_policy_id.clone().into(),
99        None,
100        attestation_exchange::AttestationReplayabilityClassV1::Replayable,
101        Vec::new(),
102        Vec::new(),
103    ) {
104        envelope
105    } else {
106        attestation_exchange::AttestationEnvelopeV1 {
107            schema_version: attestation_exchange::ATTESTATION_ENVELOPE_V1_SCHEMA.to_string(),
108            attestation_envelope_id,
109            artifact_family: "mcp-tool-invocation".into(),
110            artifact_version: "v1".into(),
111            content_digest: receipt.input_digest.clone(),
112            schema_identity: "mcp-tool".into(),
113            signer_identity,
114            signing_time,
115            trust_root_set_id: trust_root_set_id.into(),
116            provenance_summary,
117            disclosure_policy_id: disclosure_policy_id.into(),
118            artifact_admission_policy_id: None,
119            replayability_class: attestation_exchange::AttestationReplayabilityClassV1::Replayable,
120            revocation_refs: Vec::new(),
121            supersession_refs: Vec::new(),
122        }
123    }
124}
125
126pub fn requires_explicit_permit(side_effect: &ToolSideEffectClass) -> bool {
127    !matches!(
128        side_effect,
129        ToolSideEffectClass::ReadOnly | ToolSideEffectClass::Analysis
130    )
131}
132
133pub fn is_dangerous_without_permit(side_effect: &ToolSideEffectClass) -> bool {
134    matches!(
135        side_effect,
136        ToolSideEffectClass::PreviewWrite | ToolSideEffectClass::Write | ToolSideEffectClass::Admin
137    )
138}
139
140#[derive(Debug, Error, PartialEq, Eq)]
141pub enum PathSafetyError {
142    #[error("path traversal is not allowed")]
143    TraversalNotAllowed,
144    #[error("path is outside sandbox root {root}")]
145    OutsideSandbox { root: String },
146    #[error("path is in sensitive prefix {prefix}")]
147    SensitivePrefix { prefix: String },
148    #[error("path contains hidden or sensitive component {component}")]
149    HiddenOrSensitiveComponent { component: String },
150}
151
152pub fn path_contains_parent_dir(path: &Path) -> bool {
153    path.components()
154        .any(|component| matches!(component, Component::ParentDir))
155}
156
157pub fn expand_home_path(path: &str, home: &Path) -> PathBuf {
158    if path == "~" {
159        return home.to_path_buf();
160    }
161    if let Some(rest) = path.strip_prefix("~/") {
162        return home.join(rest);
163    }
164    PathBuf::from(path)
165}
166
167pub fn validate_sandbox_path(path: &Path, sandbox_root: &Path) -> Result<(), PathSafetyError> {
168    if path_contains_parent_dir(path) {
169        return Err(PathSafetyError::TraversalNotAllowed);
170    }
171    if !path.starts_with(sandbox_root) {
172        return Err(PathSafetyError::OutsideSandbox {
173            root: "<sandbox-root>".into(),
174        });
175    }
176
177    let relative = path.strip_prefix(sandbox_root).unwrap_or(path);
178    for component in relative.components() {
179        let Component::Normal(name) = component else {
180            continue;
181        };
182        let name = name.to_string_lossy();
183        let folded = name.to_ascii_lowercase();
184        if is_sensitive_component(&folded) {
185            return Err(PathSafetyError::SensitivePrefix {
186                prefix: name.into_owned(),
187            });
188        }
189        if folded.starts_with('.') {
190            return Err(PathSafetyError::HiddenOrSensitiveComponent {
191                component: name.into_owned(),
192            });
193        }
194    }
195    Ok(())
196}
197
198fn is_sensitive_component(component: &str) -> bool {
199    matches!(
200        component,
201        ".git"
202            | ".git-credentials"
203            | ".env"
204            | ".env.local"
205            | ".npmrc"
206            | ".aws"
207            | ".ssh"
208            | ".gnupg"
209            | ".cargo"
210            | ".recall"
211            | ".password-store"
212            | "id_rsa"
213            | "id_ed25519"
214    )
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    use llm_tool_runtime::{
222        McpSurfaceKind, ToolApprovalKind, ToolBackendKind, ToolExposureMode, ToolIdempotencyClass,
223        ToolOutputMode,
224    };
225
226    fn mcp_descriptor(side_effect_class: ToolSideEffectClass) -> llm_tool_runtime::ToolDescriptor {
227        llm_tool_runtime::ToolDescriptor {
228            name: "mcp.tool".to_string(),
229            version: "1.0.0".to_string(),
230            description: Some("test tool".to_string()),
231            backend_kind: ToolBackendKind::RemoteMcp,
232            input_schema: serde_json::json!({"type": "object"}),
233            output_mode: ToolOutputMode::StructuredJson,
234            read_only: true,
235            side_effect_class,
236            idempotency_class: ToolIdempotencyClass::Idempotent,
237            approval_kind: ToolApprovalKind::None,
238            timeout_ms: 1000,
239            concurrency_key: None,
240            cache_ttl_ms: None,
241            exposure_mode: ToolExposureMode::Auto,
242            mcp_surface_kind: McpSurfaceKind::Tool,
243            exposure_policy: Default::default(),
244            receipt_persistence: Default::default(),
245            output_size_limit_bytes: None,
246            provider_payload: None,
247        }
248    }
249
250    #[test]
251    fn side_effects_require_canonical_permit_classification() {
252        assert!(requires_explicit_permit(&ToolSideEffectClass::Write));
253        assert!(!requires_explicit_permit(&ToolSideEffectClass::ReadOnly));
254        assert!(is_dangerous_without_permit(&ToolSideEffectClass::Admin));
255    }
256
257    #[test]
258    fn evaluate_mcp_tool_safety_classifies_readonly_as_allow() {
259        let descriptor = mcp_descriptor(ToolSideEffectClass::ReadOnly);
260        let report = evaluate_mcp_tool_safety(&descriptor);
261
262        assert_eq!(report.tool_name, "mcp.tool");
263        assert_eq!(report.side_effect_class, "read-only");
264        assert!(!report.requires_permit);
265        assert!(!report.is_dangerous);
266        assert_eq!(report.risk_label, "low");
267        assert_eq!(report.recommendation, "allow");
268    }
269
270    #[test]
271    fn evaluate_mcp_tool_safety_classifies_write_as_deny_by_default() {
272        let descriptor = mcp_descriptor(ToolSideEffectClass::Write);
273        let report = evaluate_mcp_tool_safety(&descriptor);
274
275        assert_eq!(report.tool_name, "mcp.tool");
276        assert_eq!(report.side_effect_class, "write");
277        assert!(report.requires_permit);
278        assert!(report.is_dangerous);
279        assert_eq!(report.risk_label, "high");
280        assert_eq!(report.recommendation, "permit-required");
281    }
282
283    #[test]
284    fn path_safety_rejects_traversal_and_sensitive_prefixes() {
285        let home = Path::new("/home/user");
286
287        assert!(path_contains_parent_dir(Path::new("../secret.txt")));
288        assert_eq!(expand_home_path("~/repo", home), home.join("repo"));
289        assert_eq!(
290            validate_sandbox_path(Path::new("/home/user/.ssh/id_rsa"), home),
291            Err(PathSafetyError::SensitivePrefix {
292                prefix: ".ssh".into()
293            })
294        );
295        assert_eq!(
296            validate_sandbox_path(Path::new("/home/user/.npmrc"), home),
297            Err(PathSafetyError::SensitivePrefix {
298                prefix: ".npmrc".into()
299            })
300        );
301        assert_eq!(
302            validate_sandbox_path(Path::new("/home/user/.hidden"), home),
303            Err(PathSafetyError::HiddenOrSensitiveComponent {
304                component: ".hidden".into()
305            })
306        );
307        assert_eq!(
308            validate_sandbox_path(Path::new("/tmp/file.txt"), home),
309            Err(PathSafetyError::OutsideSandbox {
310                root: "<sandbox-root>".into()
311            })
312        );
313        assert_eq!(
314            validate_sandbox_path(Path::new("/home/user/repo"), home),
315            Ok(())
316        );
317    }
318}