1use std::fmt;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::evidence::{digest, hmac_sha256, redact};
8
9pub const TOOL_LEASE_PROTOCOL: &str = "agent_graph.tool_lease.v1";
10pub const MAX_RECEIPT_SUMMARY_BYTES: usize = 4096;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ToolEffect {
15 ReadOnly,
16 LocalMutation,
17 ExternalEffect,
18 AuthorityChange,
19 RecursiveOrchestration,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct ToolCounters {
25 pub tool_calls: u64,
26 pub recursive_calls: u64,
27 pub children: u64,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct ToolLease {
33 pub protocol: String,
34 pub lease_id: String,
35 pub lineage_id: String,
36 pub graph_id: String,
37 pub graph_version: String,
38 pub run_id: String,
39 pub node_id: String,
40 pub issued_at: DateTime<Utc>,
41 pub expires_at: DateTime<Utc>,
42 pub tool_allowlist: Vec<String>,
43 pub effect_allowlist: Vec<ToolEffect>,
44 pub max_tool_calls: u64,
45 pub max_recursive_calls: u64,
46 pub max_agent_depth: u64,
47 pub max_graph_depth: u64,
48 pub max_children: u64,
49 pub agent_depth: u64,
50 pub graph_depth: u64,
51 pub active_stack: Vec<String>,
52 pub counters: ToolCounters,
53 pub parent_receipt_digest: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct SignedToolLease {
59 pub lease: ToolLease,
60 pub signature: String,
61}
62
63#[derive(Debug, Clone, Copy)]
64pub struct LeaseBinding<'a> {
65 pub graph_id: &'a str,
66 pub graph_version: &'a str,
67 pub run_id: &'a str,
68 pub node_id: &'a str,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct ToolInvocation {
74 pub graph_id: String,
75 pub graph_version: String,
76 pub run_id: String,
77 pub node_id: String,
78 pub attempt: u64,
79 pub tool_name: String,
80 pub arguments: Value,
81 pub effect: ToolEffect,
82 pub recursion_identity: Option<String>,
83 pub parent_receipt_digest: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct ToolCallIntent {
89 pub protocol: String,
90 pub call_id: String,
91 pub lease_id: String,
92 pub lease_digest: String,
93 pub lineage_id: String,
94 pub graph_id: String,
95 pub graph_version: String,
96 pub run_id: String,
97 pub node_id: String,
98 pub attempt: u64,
99 pub tool_name: String,
100 pub arguments_digest: String,
101 pub effect: ToolEffect,
102 pub parent_receipt_digest: Option<String>,
103 pub reserved_at: DateTime<Utc>,
104 pub signature: String,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum ReceiptOutcome {
110 Succeeded,
111 Failed,
112 Blocked,
113 Cancelled,
114 Indeterminate,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ToolCallReceipt {
120 pub protocol: String,
121 pub call_id: String,
122 pub intent_digest: String,
123 pub lineage_id: String,
124 pub outcome: ReceiptOutcome,
125 pub result_digest: String,
126 pub redacted_summary: String,
127 pub parent_receipt_digest: Option<String>,
128 pub completed_at: DateTime<Utc>,
129 pub signature: String,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct CallReservation {
134 pub intent: ToolCallIntent,
135 pub updated_lease: SignedToolLease,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum ToolPolicyError {
140 LeaseProtocolUnsupported,
141 LeaseIdentityInvalid,
142 LeaseSignatureRequired,
143 LeaseSignatureInvalid,
144 LeaseExpired,
145 LeaseNotYetValid,
146 LeaseBindingMismatch,
147 LeaseScopeInvalid,
148 ToolNotGranted,
149 EffectNotGranted,
150 EffectClassificationMismatch,
151 ToolBudgetExhausted,
152 RecursiveBudgetExhausted,
153 ChildBudgetExhausted,
154 AgentDepthExceeded,
155 GraphDepthExceeded,
156 RecursionCycleDetected,
157 InvocationInvalid,
158 IntentSignatureInvalid,
159 ReceiptSignatureInvalid,
160 ReceiptIntentMismatch,
161 ReceiptChainMismatch,
162 ReceiptSummaryTooLarge,
163 SerializationFailed,
164}
165
166impl fmt::Display for ToolPolicyError {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 write!(f, "{self:?}")
169 }
170}
171
172impl std::error::Error for ToolPolicyError {}
173
174fn signed_value<T: Serialize>(value: &T) -> Result<Value, ToolPolicyError> {
175 serde_json::to_value(value).map_err(|_| ToolPolicyError::SerializationFailed)
176}
177
178fn secure_eq(left: &str, right: &str) -> bool {
179 if left.len() != right.len() {
180 return false;
181 }
182 left.as_bytes()
183 .iter()
184 .zip(right.as_bytes())
185 .fold(0u8, |difference, (a, b)| difference | (a ^ b))
186 == 0
187}
188
189pub fn issue_lease(lease: ToolLease, key: &[u8]) -> Result<SignedToolLease, ToolPolicyError> {
190 validate_lease_shape(&lease)?;
191 let signature = hmac_sha256(&signed_value(&lease)?, key);
192 Ok(SignedToolLease { lease, signature })
193}
194
195fn validate_lease_shape(lease: &ToolLease) -> Result<(), ToolPolicyError> {
196 if lease.protocol != TOOL_LEASE_PROTOCOL {
197 return Err(ToolPolicyError::LeaseProtocolUnsupported);
198 }
199 if lease.lease_id.trim().is_empty()
200 || lease.lineage_id.trim().is_empty()
201 || lease.graph_id.trim().is_empty()
202 || lease.graph_version.trim().is_empty()
203 || lease.run_id.trim().is_empty()
204 || lease.node_id.trim().is_empty()
205 {
206 return Err(ToolPolicyError::LeaseIdentityInvalid);
207 }
208 if lease.expires_at <= lease.issued_at
209 || lease.tool_allowlist.is_empty()
210 || lease.effect_allowlist.is_empty()
211 || lease.max_tool_calls == 0
212 {
213 return Err(ToolPolicyError::LeaseScopeInvalid);
214 }
215 if lease.agent_depth > lease.max_agent_depth {
216 return Err(ToolPolicyError::AgentDepthExceeded);
217 }
218 if lease.graph_depth > lease.max_graph_depth {
219 return Err(ToolPolicyError::GraphDepthExceeded);
220 }
221 if lease.counters.tool_calls > lease.max_tool_calls
222 || lease.counters.recursive_calls > lease.max_recursive_calls
223 || lease.counters.children > lease.max_children
224 {
225 return Err(ToolPolicyError::LeaseScopeInvalid);
226 }
227 Ok(())
228}
229
230pub fn verify_lease(
231 signed: &SignedToolLease,
232 key: &[u8],
233 now: DateTime<Utc>,
234 binding: LeaseBinding<'_>,
235) -> Result<(), ToolPolicyError> {
236 validate_lease_shape(&signed.lease)?;
237 if signed.signature.is_empty() {
238 return Err(ToolPolicyError::LeaseSignatureRequired);
239 }
240 let expected = hmac_sha256(&signed_value(&signed.lease)?, key);
241 if !secure_eq(&expected, &signed.signature) {
242 return Err(ToolPolicyError::LeaseSignatureInvalid);
243 }
244 if signed.lease.expires_at <= now {
245 return Err(ToolPolicyError::LeaseExpired);
246 }
247 if signed.lease.issued_at > now {
248 return Err(ToolPolicyError::LeaseNotYetValid);
249 }
250 if signed.lease.graph_id != binding.graph_id
251 || signed.lease.graph_version != binding.graph_version
252 || signed.lease.run_id != binding.run_id
253 || signed.lease.node_id != binding.node_id
254 {
255 return Err(ToolPolicyError::LeaseBindingMismatch);
256 }
257 Ok(())
258}
259
260fn tool_granted(allowlist: &[String], tool_name: &str) -> bool {
261 allowlist
262 .iter()
263 .any(|candidate| candidate == "*" || candidate == tool_name)
264}
265
266fn is_recursive(effect: ToolEffect) -> bool {
267 effect == ToolEffect::RecursiveOrchestration
268}
269
270pub fn reserve_call(
271 signed: &SignedToolLease,
272 key: &[u8],
273 now: DateTime<Utc>,
274 binding: LeaseBinding<'_>,
275 invocation: ToolInvocation,
276) -> Result<CallReservation, ToolPolicyError> {
277 verify_lease(signed, key, now, binding)?;
278 if invocation.graph_id != binding.graph_id
279 || invocation.graph_version != binding.graph_version
280 || invocation.run_id != binding.run_id
281 || invocation.node_id != binding.node_id
282 || invocation.attempt == 0
283 || invocation.tool_name.trim().is_empty()
284 {
285 return Err(ToolPolicyError::InvocationInvalid);
286 }
287 if !tool_granted(&signed.lease.tool_allowlist, &invocation.tool_name) {
288 return Err(ToolPolicyError::ToolNotGranted);
289 }
290 let classified = classify_tool(&invocation.tool_name, &invocation.arguments);
291 if classified != invocation.effect {
292 return Err(ToolPolicyError::EffectClassificationMismatch);
293 }
294 if !signed.lease.effect_allowlist.contains(&classified) {
295 return Err(ToolPolicyError::EffectNotGranted);
296 }
297 if signed.lease.counters.tool_calls >= signed.lease.max_tool_calls {
298 return Err(ToolPolicyError::ToolBudgetExhausted);
299 }
300 if is_recursive(classified)
301 && signed.lease.counters.recursive_calls >= signed.lease.max_recursive_calls
302 {
303 return Err(ToolPolicyError::RecursiveBudgetExhausted);
304 }
305 if let Some(identity) = invocation.recursion_identity.as_deref() {
306 if signed
307 .lease
308 .active_stack
309 .iter()
310 .any(|active| active == identity)
311 {
312 return Err(ToolPolicyError::RecursionCycleDetected);
313 }
314 }
315
316 let lease_digest = digest(&signed_value(&signed.lease)?);
317 let arguments_digest = digest(&redact(&invocation.arguments));
318 let call_identity = serde_json::json!({
319 "lease_digest": lease_digest,
320 "graph_id": invocation.graph_id,
321 "graph_version": invocation.graph_version,
322 "run_id": invocation.run_id,
323 "node_id": invocation.node_id,
324 "attempt": invocation.attempt,
325 "tool_name": invocation.tool_name,
326 "arguments_digest": arguments_digest,
327 "parent_receipt_digest": invocation.parent_receipt_digest,
328 });
329 let call_id = format!(
330 "tool-call-{}",
331 digest(&call_identity)
332 .strip_prefix("sha256:")
333 .unwrap_or("invalid")
334 );
335 let mut intent = ToolCallIntent {
336 protocol: "agent_graph.tool_intent.v1".into(),
337 call_id,
338 lease_id: signed.lease.lease_id.clone(),
339 lease_digest,
340 lineage_id: signed.lease.lineage_id.clone(),
341 graph_id: invocation.graph_id,
342 graph_version: invocation.graph_version,
343 run_id: invocation.run_id,
344 node_id: invocation.node_id,
345 attempt: invocation.attempt,
346 tool_name: invocation.tool_name,
347 arguments_digest,
348 effect: classified,
349 parent_receipt_digest: invocation.parent_receipt_digest,
350 reserved_at: now,
351 signature: String::new(),
352 };
353 intent.signature = sign_intent(&intent, key)?;
354
355 let mut updated = signed.lease.clone();
356 updated.counters.tool_calls = updated.counters.tool_calls.saturating_add(1);
357 if is_recursive(classified) {
358 updated.counters.recursive_calls = updated.counters.recursive_calls.saturating_add(1);
359 }
360 let updated_lease = issue_lease(updated, key)?;
361 Ok(CallReservation {
362 intent,
363 updated_lease,
364 })
365}
366
367fn intent_unsigned(intent: &ToolCallIntent) -> Result<Value, ToolPolicyError> {
368 let mut value = signed_value(intent)?;
369 let object = value
370 .as_object_mut()
371 .ok_or(ToolPolicyError::SerializationFailed)?;
372 object.insert("signature".into(), Value::String(String::new()));
373 Ok(value)
374}
375
376fn sign_intent(intent: &ToolCallIntent, key: &[u8]) -> Result<String, ToolPolicyError> {
377 Ok(hmac_sha256(&intent_unsigned(intent)?, key))
378}
379
380fn verify_intent(intent: &ToolCallIntent, key: &[u8]) -> Result<(), ToolPolicyError> {
381 let expected = sign_intent(intent, key)?;
382 if !secure_eq(&expected, &intent.signature) {
383 return Err(ToolPolicyError::IntentSignatureInvalid);
384 }
385 Ok(())
386}
387
388fn receipt_unsigned(receipt: &ToolCallReceipt) -> Result<Value, ToolPolicyError> {
389 let mut value = signed_value(receipt)?;
390 let object = value
391 .as_object_mut()
392 .ok_or(ToolPolicyError::SerializationFailed)?;
393 object.insert("signature".into(), Value::String(String::new()));
394 Ok(value)
395}
396
397impl ToolCallReceipt {
398 pub fn complete(
399 intent: &ToolCallIntent,
400 outcome: ReceiptOutcome,
401 result: &Value,
402 redacted_summary: &str,
403 completed_at: DateTime<Utc>,
404 key: &[u8],
405 ) -> Result<Self, ToolPolicyError> {
406 if redacted_summary.len() > MAX_RECEIPT_SUMMARY_BYTES {
407 return Err(ToolPolicyError::ReceiptSummaryTooLarge);
408 }
409 let mut receipt = Self {
410 protocol: "agent_graph.tool_receipt.v1".into(),
411 call_id: intent.call_id.clone(),
412 intent_digest: digest(&signed_value(intent)?),
413 lineage_id: intent.lineage_id.clone(),
414 outcome,
415 result_digest: digest(&redact(result)),
416 redacted_summary: redacted_summary.to_owned(),
417 parent_receipt_digest: intent.parent_receipt_digest.clone(),
418 completed_at,
419 signature: String::new(),
420 };
421 receipt.signature = hmac_sha256(&receipt_unsigned(&receipt)?, key);
422 Ok(receipt)
423 }
424}
425
426pub fn verify_receipt_chain(
427 intent: &ToolCallIntent,
428 receipt: &ToolCallReceipt,
429 key: &[u8],
430) -> Result<(), ToolPolicyError> {
431 verify_intent(intent, key)?;
432 if receipt.protocol != "agent_graph.tool_receipt.v1"
433 || receipt.call_id != intent.call_id
434 || receipt.lineage_id != intent.lineage_id
435 || receipt.intent_digest != digest(&signed_value(intent)?)
436 {
437 return Err(ToolPolicyError::ReceiptIntentMismatch);
438 }
439 if receipt.parent_receipt_digest != intent.parent_receipt_digest {
440 return Err(ToolPolicyError::ReceiptChainMismatch);
441 }
442 let expected = hmac_sha256(&receipt_unsigned(receipt)?, key);
443 if !secure_eq(&expected, &receipt.signature) {
444 return Err(ToolPolicyError::ReceiptSignatureInvalid);
445 }
446 Ok(())
447}
448
449pub fn classify_tool(tool_name: &str, arguments: &Value) -> ToolEffect {
450 let lower = tool_name.to_ascii_lowercase();
451 if lower == "delegate_task"
452 || lower == "execute_code"
453 || lower.starts_with("mcp__agent_graph__graph_execute")
454 || lower.starts_with("mcp__agent_graph__graph_run_start")
455 || lower.starts_with("mcp__agent_graph__graph_run_resume")
456 || lower.starts_with("mcp__agent_graph__graph_execute")
457 {
458 return ToolEffect::RecursiveOrchestration;
459 }
460 if lower == "cronjob" {
461 return match arguments.get("action").and_then(Value::as_str) {
462 Some("list") => ToolEffect::ReadOnly,
463 _ => ToolEffect::RecursiveOrchestration,
464 };
465 }
466 if matches!(
467 lower.as_str(),
468 "read_file"
469 | "search_files"
470 | "session_search"
471 | "web_search"
472 | "web_extract"
473 | "browser_snapshot"
474 | "browser_get_images"
475 | "browser_console"
476 | "ha_get_state"
477 | "ha_list_entities"
478 | "ha_list_services"
479 | "skills_list"
480 | "skill_view"
481 | "git_status"
482 ) || lower.starts_with("mcp__semantic_memory__sm_get_")
483 || lower.starts_with("mcp__semantic_memory__sm_list_")
484 || lower.starts_with("mcp__semantic_memory__sm_search")
485 || lower.starts_with("mcp__agent_graph__graph_list")
486 || lower.starts_with("mcp__agent_graph__graph_inspect")
487 || lower.starts_with("mcp__agent_graph__graph_render")
488 || lower.starts_with("mcp__agent_graph__graph_run_get")
489 || lower.starts_with("mcp__agent_graph__graph_run_events")
490 || lower.starts_with("mcp__agent_graph__graph_run_receipt")
491 {
492 return ToolEffect::ReadOnly;
493 }
494 if matches!(
495 lower.as_str(),
496 "write_file" | "patch" | "skill_manage" | "project_create" | "project_switch"
497 ) {
498 return ToolEffect::LocalMutation;
499 }
500 if matches!(
501 lower.as_str(),
502 "ha_call_service" | "browser_click" | "browser_type" | "computer_use" | "text_to_speech"
503 ) {
504 return ToolEffect::ExternalEffect;
505 }
506 if lower.contains("approval")
507 || lower.ends_with("delete_namespace")
508 || lower.ends_with("delete_fact")
509 {
510 return ToolEffect::AuthorityChange;
511 }
512 ToolEffect::ExternalEffect
513}