Skip to main content

agent_graph_mcp/
tool_runtime.rs

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}