1use 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}