1pub use crate::ToolError as ToolRuntimeError;
2use crate::{
3 validate_arguments_against_schema, ApprovalGrantEffectClass, ToolApprovalKind,
4 ToolApprovalState, ToolCall, ToolDescriptor, ToolError, ToolErrorClass, ToolExecutionPermit,
5 ToolReceipt, ToolReceiptPersistence, ToolRegistry, ToolResult, ToolRetryOwner,
6};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use stack_ids::{ContentDigest, DigestBuilder};
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13
14#[async_trait]
15pub trait ApprovalPolicy: Send + Sync {
16 async fn evaluate(
17 &self,
18 descriptor: &ToolDescriptor,
19 ctx: &crate::ToolCtx,
20 call: &ToolCall,
21 ) -> Result<ToolApprovalState, ToolError>;
22}
23
24#[derive(Default)]
25pub struct StaticApprovalPolicy;
26
27#[async_trait]
28impl ApprovalPolicy for StaticApprovalPolicy {
29 async fn evaluate(
30 &self,
31 descriptor: &ToolDescriptor,
32 ctx: &crate::ToolCtx,
33 call: &ToolCall,
34 ) -> Result<ToolApprovalState, ToolError> {
35 if descriptor_has_tokenless_read_only_path(descriptor) {
36 return Ok(ToolApprovalState::NotRequired);
37 }
38
39 let permit = ctx.execution_permit.as_ref().ok_or_else(|| {
40 ToolError::new(
41 ToolErrorClass::ApprovalRequired,
42 format!("tool {} requires an execution permit", descriptor.name),
43 )
44 })?;
45 if !execution_permit_authorizes(permit, ctx, call) {
46 return Err(ToolError::new(
47 ToolErrorClass::Denied,
48 format!("execution permit does not cover tool {}", descriptor.name),
49 ));
50 }
51
52 if descriptor.approval_kind == ToolApprovalKind::None {
53 return Ok(ToolApprovalState::NotRequired);
54 }
55
56 if let Some(grant) = ctx.approval_grant.as_ref() {
57 if approval_grant_authorizes(grant, descriptor, ctx, call, &Utc::now().to_rfc3339()) {
58 return Ok(ToolApprovalState::Approved);
59 }
60 return Err(ToolError::new(
61 ToolErrorClass::Denied,
62 format!("approval grant does not cover tool {}", descriptor.name),
63 ));
64 }
65
66 Err(ToolError::new(
67 ToolErrorClass::ApprovalRequired,
68 format!("tool {} requires approval", descriptor.name),
69 ))
70 }
71}
72
73fn descriptor_has_tokenless_read_only_path(descriptor: &ToolDescriptor) -> bool {
74 descriptor.read_only
75 && matches!(
76 descriptor.side_effect_class,
77 crate::ToolSideEffectClass::ReadOnly
78 )
79}
80
81fn execution_permit_authorizes(
82 permit: &ToolExecutionPermit,
83 ctx: &crate::ToolCtx,
84 call: &ToolCall,
85) -> bool {
86 let Some(scope) = ctx.scope.as_ref() else {
87 return false;
88 };
89 if permit.scope().namespace() != scope.namespace {
90 return false;
91 }
92 let Some(target_key) = extract_target_key(call) else {
93 return false;
94 };
95 permit.scope().target_key() == target_key
96}
97
98fn approval_grant_authorizes(
99 grant: &crate::ApprovalGrant,
100 descriptor: &ToolDescriptor,
101 ctx: &crate::ToolCtx,
102 call: &ToolCall,
103 evaluated_at: &str,
104) -> bool {
105 if grant.approver_lineage.is_empty() {
106 return false;
107 }
108 if grant
109 .expires_at
110 .as_ref()
111 .is_some_and(|expires_at| expires_at.as_str() <= evaluated_at)
112 {
113 return false;
114 }
115 if grant.scope.tool_name != descriptor.name {
116 return false;
117 }
118 if grant
119 .scope
120 .planner_stage
121 .as_ref()
122 .is_some_and(|planner_stage| planner_stage != &ctx.planner_stage)
123 {
124 return false;
125 }
126
127 let Some(required_effect_class) =
128 ApprovalGrantEffectClass::for_tool_side_effect_class(&descriptor.side_effect_class)
129 else {
130 return false;
131 };
132 if grant.scope.effect_class != required_effect_class {
133 return false;
134 }
135
136 let Some(scope) = ctx.scope.as_ref() else {
137 return false;
138 };
139 if grant.scope.namespace != scope.namespace {
140 return false;
141 }
142 if grant
143 .scope
144 .target_key
145 .as_ref()
146 .is_some_and(|target_key| Some(target_key.as_str()) != extract_target_key(call))
147 {
148 return false;
149 }
150 if let Some(permit) = ctx.execution_permit.as_ref() {
151 if grant
152 .decision_id
153 .as_ref()
154 .is_some_and(|decision_id| decision_id != permit.decision_id())
155 {
156 return false;
157 }
158 if grant
159 .approval_record_id
160 .as_ref()
161 .is_some_and(|approval_record_id| {
162 permit.approval_record_id() != Some(approval_record_id)
163 })
164 {
165 return false;
166 }
167 }
168
169 true
170}
171
172fn extract_target_key(call: &ToolCall) -> Option<&str> {
173 ["target", "artifact_id", "candidate_id", "task_id"]
174 .into_iter()
175 .find_map(|key| call.arguments.get(key).and_then(|value| value.as_str()))
176}
177
178#[async_trait]
179pub trait ToolReceiptSink: Send + Sync {
180 async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError>;
181}
182
183#[derive(Default)]
184pub struct InMemoryReceiptSink {
185 receipts: std::sync::Mutex<Vec<ToolReceipt>>,
186}
187
188impl InMemoryReceiptSink {
189 pub fn receipts(&self) -> Vec<ToolReceipt> {
191 self.receipts
192 .lock()
193 .unwrap_or_else(|poisoned| poisoned.into_inner())
194 .clone()
195 }
196}
197
198#[async_trait]
199impl ToolReceiptSink for InMemoryReceiptSink {
200 async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError> {
201 self.receipts
202 .lock()
203 .unwrap_or_else(|poisoned| poisoned.into_inner())
204 .push(receipt.clone());
205 Ok(())
206 }
207}
208
209#[derive(Debug, Clone)]
210pub struct ToolRuntimeConfig {
211 pub default_timeout_ms: u64,
212 pub default_output_size_limit_bytes: usize,
213}
214
215impl Default for ToolRuntimeConfig {
216 fn default() -> Self {
217 Self {
218 default_timeout_ms: 30_000,
219 default_output_size_limit_bytes: 256 * 1024,
220 }
221 }
222}
223
224#[derive(Debug, Clone)]
225pub struct ToolExecution {
226 pub result: Result<ToolResult, ToolError>,
227 pub receipt: ToolReceipt,
228}
229
230pub struct ToolRuntime {
231 registry: ToolRegistry,
232 approval_policy: Arc<dyn ApprovalPolicy>,
233 receipt_sink: Option<Arc<dyn ToolReceiptSink>>,
234 config: ToolRuntimeConfig,
235}
236
237impl ToolRuntime {
238 pub fn new(registry: ToolRegistry) -> Self {
240 Self {
241 registry,
242 approval_policy: Arc::new(StaticApprovalPolicy),
243 receipt_sink: None,
244 config: ToolRuntimeConfig::default(),
245 }
246 }
247
248 pub fn with_config(mut self, config: ToolRuntimeConfig) -> Self {
250 self.config = config;
251 self
252 }
253
254 pub fn with_approval_policy(mut self, approval_policy: Arc<dyn ApprovalPolicy>) -> Self {
256 self.approval_policy = approval_policy;
257 self
258 }
259
260 pub fn with_receipt_sink(mut self, receipt_sink: Arc<dyn ToolReceiptSink>) -> Self {
262 self.receipt_sink = Some(receipt_sink);
263 self
264 }
265
266 pub fn registry(&self) -> &ToolRegistry {
268 &self.registry
269 }
270
271 pub async fn execute(
272 &self,
273 ctx: &crate::ToolCtx,
274 call: &ToolCall,
275 permit: Option<&ToolExecutionPermit>,
276 cancel: Option<&AtomicBool>,
277 ) -> ToolExecution {
278 let started_at = Utc::now();
279 let mut execution_ctx = ctx.clone();
280 execution_ctx.execution_permit = permit.cloned();
281 let descriptor = match self.registry.get(&call.descriptor_name) {
282 Some(tool) => tool.descriptor().clone(),
283 None => {
284 let error = ToolError::new(
285 ToolErrorClass::UnknownTool,
286 format!("unknown tool {}", call.descriptor_name),
287 );
288 let receipt = self.failure_receipt(ctx, call, started_at, error.clone(), None);
289 return ToolExecution {
290 result: Err(error),
291 receipt,
292 };
293 }
294 };
295
296 if descriptor.version != call.descriptor_version {
297 let error = ToolError::new(
298 ToolErrorClass::ProviderContract,
299 format!(
300 "tool version mismatch for {}: expected {}, got {}",
301 descriptor.name, descriptor.version, call.descriptor_version
302 ),
303 );
304 let receipt =
305 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
306 return ToolExecution {
307 result: Err(error),
308 receipt,
309 };
310 }
311
312 if let Err(error) =
313 validate_arguments_against_schema(&descriptor.input_schema, &call.arguments)
314 {
315 let receipt =
316 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
317 return ToolExecution {
318 result: Err(error),
319 receipt,
320 };
321 }
322
323 let approval_state = match self
324 .approval_policy
325 .evaluate(&descriptor, &execution_ctx, call)
326 .await
327 {
328 Ok(state) => state,
329 Err(error) => {
330 let receipt =
331 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
332 return ToolExecution {
333 result: Err(error),
334 receipt,
335 };
336 }
337 };
338
339 if matches!(approval_state, ToolApprovalState::Denied) {
340 let error = ToolError::new(
341 ToolErrorClass::Denied,
342 format!("tool {} was denied by approval policy", descriptor.name),
343 );
344 let receipt =
345 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
346 return ToolExecution {
347 result: Err(error),
348 receipt,
349 };
350 }
351
352 let Some(tool) = self.registry.get(&call.descriptor_name) else {
353 let error = ToolError::new(
354 ToolErrorClass::UnknownTool,
355 format!("tool {} disappeared before execution", call.descriptor_name),
356 );
357 let receipt =
358 self.failure_receipt(ctx, call, started_at, error.clone(), Some(&descriptor));
359 return ToolExecution {
360 result: Err(error),
361 receipt,
362 };
363 };
364 let timeout_ms = resolve_timeout_ms(&descriptor, ctx, self.config.default_timeout_ms);
365 let fut = async {
366 if let Some(flag) = cancel {
367 if flag.load(Ordering::Relaxed) {
368 return Err(ToolError::new(ToolErrorClass::Cancelled, "tool cancelled"));
369 }
370 }
371
372 let execute = tool.invoke(&execution_ctx, call);
373 match cancel {
374 Some(flag) => {
375 tokio::select! {
376 result = execute => result,
377 _ = wait_for_cancel(flag) => Err(ToolError::new(ToolErrorClass::Cancelled, "tool cancelled")),
378 }
379 }
380 None => execute.await,
381 }
382 };
383
384 let result = match tokio::time::timeout(Duration::from_millis(timeout_ms), fut).await {
385 Ok(inner) => inner,
386 Err(_) => Err(ToolError::new(
387 ToolErrorClass::Timeout,
388 format!("tool {} timed out after {}ms", descriptor.name, timeout_ms),
389 )),
390 };
391
392 let result = match result {
393 Ok(tool_result) => {
394 if let Err(error) =
395 enforce_output_size_limit(&descriptor, &tool_result, &self.config)
396 {
397 Err(error)
398 } else {
399 Ok(tool_result)
400 }
401 }
402 Err(error) => Err(error),
403 };
404
405 let receipt = match &result {
406 Ok(tool_result) => self.success_receipt(
407 ctx,
408 call,
409 &descriptor,
410 started_at,
411 tool_result,
412 approval_state.clone(),
413 ),
414 Err(error) => self.failure_receipt_with_approval(
415 ctx,
416 call,
417 started_at,
418 error.clone(),
419 &descriptor,
420 approval_state.clone(),
421 ),
422 };
423
424 if descriptor.receipt_persistence == ToolReceiptPersistence::ForgeRaw {
425 if let Some(sink) = &self.receipt_sink {
426 if let Err(error) = sink.persist(&receipt).await {
427 let receipt = self.failure_receipt_with_approval(
428 ctx,
429 call,
430 started_at,
431 error.clone(),
432 &descriptor,
433 approval_state,
434 );
435 return ToolExecution {
436 result: Err(error),
437 receipt,
438 };
439 }
440 } else {
441 let error = ToolError::new(
442 ToolErrorClass::ReceiptPersistence,
443 format!("tool {} requires a durable receipt sink", descriptor.name),
444 );
445 let receipt = self.failure_receipt_with_approval(
446 ctx,
447 call,
448 started_at,
449 error.clone(),
450 &descriptor,
451 approval_state,
452 );
453 return ToolExecution {
454 result: Err(error),
455 receipt,
456 };
457 }
458 }
459
460 ToolExecution { result, receipt }
461 }
462
463 fn success_receipt(
464 &self,
465 ctx: &crate::ToolCtx,
466 call: &ToolCall,
467 descriptor: &ToolDescriptor,
468 started_at: DateTime<Utc>,
469 result: &ToolResult,
470 approval_state: ToolApprovalState,
471 ) -> ToolReceipt {
472 ToolReceipt {
473 receipt_id: uuid::Uuid::new_v4().to_string(),
474 tool_name: descriptor.name.clone(),
475 tool_version: descriptor.version.clone(),
476 backend_kind: descriptor.backend_kind.clone(),
477 input_digest: digest_json(&call.arguments),
478 output_digest_or_refs: output_digest_or_refs(result),
479 policy_hash: policy_hash(descriptor),
480 approval_state,
481 host_identity: ctx.caller.clone(),
482 started_at: started_at.to_rfc3339(),
483 finished_at: Utc::now().to_rfc3339(),
484 trace_ctx: ctx.trace_ctx.clone(),
485 attempt_id: ctx.attempt_id.clone(),
486 trial_id: ctx.trial_id.clone(),
487 planner_stage: ctx.planner_stage.clone(),
488 deadline: ctx.deadline.clone(),
489 workload_class: ctx.workload_class.clone(),
490 budget_context: ctx.budget_context.clone(),
491 parent_receipt_id: ctx.parent_receipt_id.clone(),
492 family_receipt_id: ctx.family_receipt_id.clone(),
493 replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
494 remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
495 remote_slice_result_id: ctx.remote_slice_result_id.clone(),
496 attestation_envelope_id: ctx.attestation_envelope_id.clone(),
497 cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
498 error_class: None,
499 retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
500 replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
501 tool_run_id: call.tool_run_id.clone(),
502 provider_call_id: call.provider_call_id.clone(),
503 }
504 }
505
506 fn failure_receipt(
507 &self,
508 ctx: &crate::ToolCtx,
509 call: &ToolCall,
510 started_at: DateTime<Utc>,
511 error: ToolError,
512 descriptor: Option<&ToolDescriptor>,
513 ) -> ToolReceipt {
514 let descriptor_backend_kind = descriptor
515 .map(|desc| desc.backend_kind.clone())
516 .unwrap_or(crate::ToolBackendKind::LocalFunction);
517 let tool_version = descriptor
518 .map(|desc| desc.version.clone())
519 .unwrap_or_else(|| call.descriptor_version.clone());
520 let policy_hash = descriptor
521 .map(policy_hash)
522 .unwrap_or_else(|| ContentDigest::compute(b"missing_policy"));
523
524 ToolReceipt {
525 receipt_id: uuid::Uuid::new_v4().to_string(),
526 tool_name: call.descriptor_name.clone(),
527 tool_version,
528 backend_kind: descriptor_backend_kind,
529 input_digest: digest_json(&call.arguments),
530 output_digest_or_refs: serde_json::json!({"error": error.message}),
531 policy_hash,
532 approval_state: ToolApprovalState::Denied,
533 host_identity: ctx.caller.clone(),
534 started_at: started_at.to_rfc3339(),
535 finished_at: Utc::now().to_rfc3339(),
536 trace_ctx: ctx.trace_ctx.clone(),
537 attempt_id: ctx.attempt_id.clone(),
538 trial_id: ctx.trial_id.clone(),
539 planner_stage: ctx.planner_stage.clone(),
540 deadline: ctx.deadline.clone(),
541 workload_class: ctx.workload_class.clone(),
542 budget_context: ctx.budget_context.clone(),
543 parent_receipt_id: ctx.parent_receipt_id.clone(),
544 family_receipt_id: ctx.family_receipt_id.clone(),
545 replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
546 remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
547 remote_slice_result_id: ctx.remote_slice_result_id.clone(),
548 attestation_envelope_id: ctx.attestation_envelope_id.clone(),
549 cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
550 error_class: Some(error.class),
551 retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
552 replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
553 tool_run_id: call.tool_run_id.clone(),
554 provider_call_id: call.provider_call_id.clone(),
555 }
556 }
557
558 fn failure_receipt_with_approval(
559 &self,
560 ctx: &crate::ToolCtx,
561 call: &ToolCall,
562 started_at: DateTime<Utc>,
563 error: ToolError,
564 descriptor: &ToolDescriptor,
565 approval_state: ToolApprovalState,
566 ) -> ToolReceipt {
567 ToolReceipt {
568 receipt_id: uuid::Uuid::new_v4().to_string(),
569 tool_name: descriptor.name.clone(),
570 tool_version: descriptor.version.clone(),
571 backend_kind: descriptor.backend_kind.clone(),
572 input_digest: digest_json(&call.arguments),
573 output_digest_or_refs: serde_json::json!({"error": error.message}),
574 policy_hash: policy_hash(descriptor),
575 approval_state,
576 host_identity: ctx.caller.clone(),
577 started_at: started_at.to_rfc3339(),
578 finished_at: Utc::now().to_rfc3339(),
579 trace_ctx: ctx.trace_ctx.clone(),
580 attempt_id: ctx.attempt_id.clone(),
581 trial_id: ctx.trial_id.clone(),
582 planner_stage: ctx.planner_stage.clone(),
583 deadline: ctx.deadline.clone(),
584 workload_class: ctx.workload_class.clone(),
585 budget_context: ctx.budget_context.clone(),
586 parent_receipt_id: ctx.parent_receipt_id.clone(),
587 family_receipt_id: ctx.family_receipt_id.clone(),
588 replay_parent_receipt_id: ctx.replay_parent_receipt_id.clone(),
589 remote_oracle_lease_id: ctx.remote_oracle_lease_id.clone(),
590 remote_slice_result_id: ctx.remote_slice_result_id.clone(),
591 attestation_envelope_id: ctx.attestation_envelope_id.clone(),
592 cross_runtime_replay_ticket_id: ctx.cross_runtime_replay_ticket_id.clone(),
593 error_class: Some(error.class),
594 retry_owner: ctx.retry_owner.clone().unwrap_or(ToolRetryOwner::External),
595 replay_link: Some(format!("tool_run:{}", call.tool_run_id)),
596 tool_run_id: call.tool_run_id.clone(),
597 provider_call_id: call.provider_call_id.clone(),
598 }
599 }
600}
601
602fn resolve_timeout_ms(
603 descriptor: &ToolDescriptor,
604 ctx: &crate::ToolCtx,
605 default_timeout_ms: u64,
606) -> u64 {
607 let mut timeout_ms = if descriptor.timeout_ms == 0 {
608 default_timeout_ms
609 } else {
610 descriptor.timeout_ms
611 };
612
613 if let Some(deadline) = &ctx.deadline {
614 if let Ok(deadline_at) = DateTime::parse_from_rfc3339(deadline) {
615 let remaining = deadline_at
616 .with_timezone(&Utc)
617 .signed_duration_since(Utc::now())
618 .num_milliseconds()
619 .max(1) as u64;
620 timeout_ms = timeout_ms.min(remaining);
621 }
622 }
623
624 timeout_ms
625}
626
627fn enforce_output_size_limit(
628 descriptor: &ToolDescriptor,
629 result: &ToolResult,
630 config: &ToolRuntimeConfig,
631) -> Result<(), ToolError> {
632 let output_size_limit = descriptor
633 .output_size_limit_bytes
634 .unwrap_or(config.default_output_size_limit_bytes);
635 let output_json = serde_json::to_vec(result).unwrap_or_default();
636
637 if output_json.len() > output_size_limit {
638 return Err(ToolError::new(
639 ToolErrorClass::OutputTooLarge,
640 format!(
641 "tool {} output exceeded {} bytes",
642 descriptor.name, output_size_limit
643 ),
644 ));
645 }
646
647 Ok(())
648}
649
650fn digest_json(value: &serde_json::Value) -> ContentDigest {
651 let mut builder = DigestBuilder::new();
652 if let Err(e) = builder.update_json(value) {
653 tracing::warn!(error = %e, "digest_json: update_json failed; finalizing partial digest");
654 }
655 builder.finalize()
656}
657
658fn policy_hash(descriptor: &ToolDescriptor) -> ContentDigest {
659 let payload = serde_json::json!({
660 "read_only": descriptor.read_only,
661 "side_effect_class": descriptor.side_effect_class,
662 "idempotency_class": descriptor.idempotency_class,
663 "approval_kind": descriptor.approval_kind,
664 "exposure_mode": descriptor.exposure_mode,
665 "receipt_persistence": descriptor.receipt_persistence,
666 });
667 digest_json(&payload)
668}
669
670fn output_digest_or_refs(result: &ToolResult) -> serde_json::Value {
671 match result.mode {
672 crate::ToolOutputMode::ArtifactRefs => serde_json::json!({
673 "artifacts": result.payload,
674 }),
675 _ => serde_json::json!({
676 "digest": digest_json(&result.payload).hex().to_string(),
677 }),
678 }
679}
680
681async fn wait_for_cancel(flag: &AtomicBool) {
682 while !flag.load(Ordering::Relaxed) {
683 tokio::time::sleep(Duration::from_millis(10)).await;
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::{
691 Tool, ToolApprovalKind, ToolBackendKind, ToolBudgetContext, ToolCall, ToolCtx,
692 ToolDescriptor, ToolExposureMode, ToolExposurePolicy, ToolIdempotencyClass, ToolOriginKind,
693 ToolOutputMode, ToolPlannerStage, ToolReceiptPersistence, ToolResult, ToolSideEffectClass,
694 };
695 use async_trait::async_trait;
696 use semantic_memory_forge::FORGE_TOOL_RECEIPT_V2_SCHEMA;
697 use serde_json::json;
698 use stack_ids::{AttemptId, TraceCtx, TrialId};
699
700 #[derive(Clone)]
701 struct EchoTool {
702 descriptor: ToolDescriptor,
703 }
704
705 struct SlowTool {
706 descriptor: ToolDescriptor,
707 delay_ms: u64,
708 }
709
710 #[async_trait]
711 impl Tool for EchoTool {
712 fn descriptor(&self) -> &ToolDescriptor {
713 &self.descriptor
714 }
715
716 async fn invoke(&self, _ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
717 Ok(ToolResult::json(call.arguments.clone()))
718 }
719 }
720
721 #[async_trait]
722 impl Tool for SlowTool {
723 fn descriptor(&self) -> &ToolDescriptor {
724 &self.descriptor
725 }
726
727 async fn invoke(&self, _ctx: &ToolCtx, _call: &ToolCall) -> Result<ToolResult, ToolError> {
728 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
729 Ok(ToolResult::json(json!({"message": "slow"})))
730 }
731 }
732
733 struct RecordingSink(std::sync::Mutex<Vec<ToolReceipt>>);
734
735 #[async_trait]
736 impl ToolReceiptSink for RecordingSink {
737 async fn persist(&self, receipt: &ToolReceipt) -> Result<(), ToolError> {
738 self.0.lock().unwrap().push(receipt.clone());
739 Ok(())
740 }
741 }
742
743 fn echo_descriptor() -> ToolDescriptor {
744 ToolDescriptor {
745 name: "echo".into(),
746 version: "1.0.0".into(),
747 description: Some("Echo the input back".into()),
748 backend_kind: ToolBackendKind::LocalFunction,
749 input_schema: json!({
750 "type": "object",
751 "required": ["message"],
752 "properties": {"message": {"type": "string"}},
753 "additionalProperties": false
754 }),
755 output_mode: ToolOutputMode::StructuredJson,
756 read_only: true,
757 side_effect_class: ToolSideEffectClass::ReadOnly,
758 idempotency_class: ToolIdempotencyClass::Idempotent,
759 approval_kind: ToolApprovalKind::None,
760 timeout_ms: 1_000,
761 concurrency_key: None,
762 cache_ttl_ms: None,
763 exposure_mode: ToolExposureMode::Auto,
764 mcp_surface_kind: crate::McpSurfaceKind::Tool,
765 exposure_policy: ToolExposurePolicy {
766 max_calls_per_turn: 1,
767 ..Default::default()
768 },
769 receipt_persistence: ToolReceiptPersistence::ForgeRaw,
770 output_size_limit_bytes: None,
771 provider_payload: None,
772 }
773 }
774
775 fn tool_ctx() -> ToolCtx {
776 ToolCtx {
777 trace_ctx: TraceCtx::generate(),
778 attempt_id: AttemptId::generate(),
779 trial_id: TrialId::generate(),
780 deadline: None,
781 workload_class: None,
782 budget_context: None,
783 scope: None,
784 dry_run: false,
785 approval_grant: None,
786 execution_permit: None,
787 idempotency_key: None,
788 caller: "test-host".into(),
789 planner_stage: ToolPlannerStage::Execution,
790 parent_receipt_id: None,
791 family_receipt_id: None,
792 replay_parent_receipt_id: None,
793 remote_oracle_lease_id: None,
794 remote_slice_result_id: None,
795 attestation_envelope_id: None,
796 cross_runtime_replay_ticket_id: None,
797 retry_owner: Some(ToolRetryOwner::LlmPipeline),
798 }
799 }
800
801 fn execution_permit_for(target_key: &str) -> crate::ToolExecutionPermit {
802 crate::ToolExecutionPermit::new(
803 stack_ids::ExecutionPermitId::generate(),
804 stack_ids::PolicyDecisionId::generate(),
805 None,
806 "runtime-tests",
807 target_key,
808 )
809 }
810
811 fn approval_grant_for(
812 tool_name: &str,
813 effect_class: crate::ApprovalGrantEffectClass,
814 namespace: &str,
815 target_key: Option<&str>,
816 ) -> crate::ApprovalGrant {
817 crate::ApprovalGrant::new(
818 "policy-1",
819 crate::ApprovalGrantScope {
820 namespace: namespace.into(),
821 target_key: target_key.map(str::to_owned),
822 tool_name: tool_name.into(),
823 effect_class,
824 planner_stage: Some(ToolPlannerStage::Execution),
825 },
826 vec!["operator".into()],
827 "2026-03-12T00:00:00Z",
828 None,
829 crate::ApprovalGrantCitation {
830 applicability_context_id: stack_ids::ApplicabilityContextId::new(
831 "applicability-context-1",
832 ),
833 profile_set_id: stack_ids::ProfileSetId::new("profile-set-1"),
834 composition_receipt_id: stack_ids::CompositionReceiptId::new(
835 "composition-receipt-1",
836 ),
837 effective_constitution_id: stack_ids::EffectiveConstitutionId::new(
838 "effective-constitution-1",
839 ),
840 compiled_obligation_set_id: stack_ids::CompiledObligationSetId::new(
841 "compiled-obligation-set-1",
842 ),
843 },
844 )
845 }
846
847 #[tokio::test]
848 async fn runtime_dispatches_and_persists_receipts() {
849 let mut registry = ToolRegistry::new();
850 registry.register(EchoTool {
851 descriptor: echo_descriptor(),
852 });
853 let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
854 let runtime = ToolRuntime::new(registry).with_receipt_sink(sink.clone());
855 let call = ToolCall::new(
856 "echo",
857 "1.0.0",
858 json!({"message": "hello"}),
859 ToolOriginKind::Test,
860 );
861
862 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
863 let value = execution.result.unwrap().payload;
864 assert_eq!(value["message"], "hello");
865 assert!(execution.receipt.error_class.is_none());
866 assert_eq!(sink.0.lock().unwrap().len(), 1);
867 }
868
869 #[tokio::test]
870 async fn runtime_returns_structured_unknown_tool_failure() {
871 let runtime = ToolRuntime::new(ToolRegistry::new());
872 let call = ToolCall::new("missing", "1.0.0", json!({}), ToolOriginKind::Test);
873
874 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
875 let error = execution.result.unwrap_err();
876
877 assert_eq!(error.class, ToolErrorClass::UnknownTool);
878 assert_eq!(
879 execution.receipt.error_class,
880 Some(ToolErrorClass::UnknownTool)
881 );
882 }
883
884 #[tokio::test]
885 async fn runtime_returns_structured_timeout_failure() {
886 let mut registry = ToolRegistry::new();
887 let mut descriptor = echo_descriptor();
888 descriptor.name = "slow".into();
889 descriptor.timeout_ms = 1;
890 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
891 registry.register(SlowTool {
892 descriptor,
893 delay_ms: 20,
894 });
895 let runtime = ToolRuntime::new(registry);
896 let call = ToolCall::new(
897 "slow",
898 "1.0.0",
899 json!({"message": "slow"}),
900 ToolOriginKind::Test,
901 );
902
903 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
904 let error = execution.result.unwrap_err();
905
906 assert_eq!(error.class, ToolErrorClass::Timeout);
907 assert_eq!(execution.receipt.error_class, Some(ToolErrorClass::Timeout));
908 }
909
910 #[tokio::test]
911 async fn runtime_returns_structured_cancelled_failure() {
912 let mut registry = ToolRegistry::new();
913 let mut descriptor = echo_descriptor();
914 descriptor.name = "slow".into();
915 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
916 registry.register(SlowTool {
917 descriptor,
918 delay_ms: 20,
919 });
920 let runtime = ToolRuntime::new(registry);
921 let call = ToolCall::new(
922 "slow",
923 "1.0.0",
924 json!({"message": "slow"}),
925 ToolOriginKind::Test,
926 );
927 let cancel = AtomicBool::new(true);
928
929 let execution = runtime
930 .execute(&tool_ctx(), &call, None, Some(&cancel))
931 .await;
932 let error = execution.result.unwrap_err();
933
934 assert_eq!(error.class, ToolErrorClass::Cancelled);
935 assert_eq!(
936 execution.receipt.error_class,
937 Some(ToolErrorClass::Cancelled)
938 );
939 }
940
941 #[tokio::test]
942 async fn runtime_returns_structured_receipt_persistence_failure() {
943 let mut registry = ToolRegistry::new();
944 registry.register(EchoTool {
945 descriptor: echo_descriptor(),
946 });
947 let runtime = ToolRuntime::new(registry);
948 let call = ToolCall::new(
949 "echo",
950 "1.0.0",
951 json!({"message": "hello"}),
952 ToolOriginKind::Test,
953 );
954
955 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
956 let error = execution.result.unwrap_err();
957
958 assert_eq!(error.class, ToolErrorClass::ReceiptPersistence);
959 assert_eq!(
960 execution.receipt.error_class,
961 Some(ToolErrorClass::ReceiptPersistence)
962 );
963 }
964
965 #[tokio::test]
966 async fn runtime_receipt_carries_budget_and_lineage_context() {
967 let mut registry = ToolRegistry::new();
968 registry.register(EchoTool {
969 descriptor: echo_descriptor(),
970 });
971 let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
972 let runtime = ToolRuntime::new(registry).with_receipt_sink(sink.clone());
973 let call = ToolCall::new(
974 "echo",
975 "1.0.0",
976 json!({"message": "lineage"}),
977 ToolOriginKind::Test,
978 );
979 let mut ctx = tool_ctx();
980 ctx.deadline = Some("2026-03-12T00:00:10Z".into());
981 ctx.workload_class = Some("verification".into());
982 ctx.budget_context = Some(ToolBudgetContext {
983 budget_kind: Some("control_plane".into()),
984 max_steps: Some(3),
985 time_budget_ms: Some(750),
986 cost_budget_units: Some(12),
987 });
988 ctx.parent_receipt_id = Some("dispatch-1".into());
989 ctx.family_receipt_id = Some("attempt-family-1".into());
990 ctx.replay_parent_receipt_id = Some("retry-0".into());
991 ctx.remote_oracle_lease_id = Some(stack_ids::RemoteOracleLeaseId::new("lease-1"));
992 ctx.remote_slice_result_id = Some(stack_ids::RemoteSliceResultId::new("result-1"));
993 ctx.attestation_envelope_id = Some(stack_ids::AttestationEnvelopeId::new("attestation-1"));
994 ctx.cross_runtime_replay_ticket_id = Some(stack_ids::CrossRuntimeReplayTicketId::new(
995 "cross-runtime-replay-ticket-1",
996 ));
997 ctx.planner_stage = ToolPlannerStage::Audit;
998
999 let execution = runtime.execute(&ctx, &call, None, None).await;
1000 assert!(execution.result.is_ok());
1001 assert_eq!(
1002 execution.receipt.deadline.as_deref(),
1003 Some("2026-03-12T00:00:10Z")
1004 );
1005 assert_eq!(
1006 execution.receipt.workload_class.as_deref(),
1007 Some("verification")
1008 );
1009 assert_eq!(
1010 execution
1011 .receipt
1012 .budget_context
1013 .as_ref()
1014 .and_then(|budget| budget.max_steps),
1015 Some(3)
1016 );
1017 assert_eq!(
1018 execution.receipt.parent_receipt_id.as_deref(),
1019 Some("dispatch-1")
1020 );
1021 assert_eq!(
1022 execution.receipt.family_receipt_id.as_deref(),
1023 Some("attempt-family-1")
1024 );
1025 assert_eq!(
1026 execution.receipt.replay_parent_receipt_id.as_deref(),
1027 Some("retry-0")
1028 );
1029 assert_eq!(
1030 execution
1031 .receipt
1032 .remote_oracle_lease_id
1033 .as_ref()
1034 .map(|id| id.as_str()),
1035 Some("lease-1")
1036 );
1037 assert_eq!(
1038 execution
1039 .receipt
1040 .remote_slice_result_id
1041 .as_ref()
1042 .map(|id| id.as_str()),
1043 Some("result-1")
1044 );
1045 assert_eq!(
1046 execution
1047 .receipt
1048 .attestation_envelope_id
1049 .as_ref()
1050 .map(|id| id.as_str()),
1051 Some("attestation-1")
1052 );
1053 assert_eq!(
1054 execution
1055 .receipt
1056 .cross_runtime_replay_ticket_id
1057 .as_ref()
1058 .map(|id| id.as_str()),
1059 Some("cross-runtime-replay-ticket-1")
1060 );
1061 assert_eq!(execution.receipt.planner_stage, ToolPlannerStage::Audit);
1062 assert_eq!(sink.0.lock().unwrap().len(), 1);
1063 }
1064
1065 #[tokio::test]
1066 async fn runtime_receipt_normalizes_into_canonical_forge_receipt() {
1067 let mut registry = ToolRegistry::new();
1068 registry.register(EchoTool {
1069 descriptor: echo_descriptor(),
1070 });
1071 let sink = Arc::new(RecordingSink(std::sync::Mutex::new(Vec::new())));
1072 let runtime = ToolRuntime::new(registry).with_receipt_sink(sink);
1073 let call = ToolCall::new(
1074 "echo",
1075 "1.0.0",
1076 json!({"message": "canonical"}),
1077 ToolOriginKind::Test,
1078 );
1079 let mut ctx = tool_ctx();
1080 ctx.deadline = Some("2026-03-12T00:00:10Z".into());
1081 ctx.workload_class = Some("verification".into());
1082 ctx.budget_context = Some(ToolBudgetContext {
1083 budget_kind: Some("control_plane".into()),
1084 max_steps: Some(3),
1085 time_budget_ms: Some(750),
1086 cost_budget_units: Some(12),
1087 });
1088 ctx.parent_receipt_id = Some("dispatch-1".into());
1089 ctx.family_receipt_id = Some("attempt-family-1".into());
1090 ctx.replay_parent_receipt_id = Some("retry-0".into());
1091
1092 let execution = runtime.execute(&ctx, &call, None, None).await;
1093 let receipt = execution.receipt;
1094 let forge_receipt = receipt.to_forge_tool_receipt_v2(json!({
1095 "origin_kind": "test",
1096 "payload_kind": "echo",
1097 }));
1098
1099 assert_eq!(forge_receipt.schema_version, FORGE_TOOL_RECEIPT_V2_SCHEMA);
1100 assert_eq!(forge_receipt.receipt_id, receipt.receipt_id);
1101 assert_eq!(forge_receipt.trace_ctx, receipt.trace_ctx);
1102 assert_eq!(forge_receipt.attempt_id, receipt.attempt_id);
1103 assert_eq!(forge_receipt.trial_id, receipt.trial_id);
1104 assert_eq!(forge_receipt.backend_kind, "local_function");
1105 assert_eq!(forge_receipt.approval_state, "not_required");
1106 assert_eq!(forge_receipt.planner_stage, "execution");
1107 assert_eq!(forge_receipt.retry_owner, "llm_pipeline");
1108 assert_eq!(
1109 forge_receipt.parent_receipt_id.as_deref(),
1110 Some("dispatch-1")
1111 );
1112 assert_eq!(
1113 forge_receipt.family_receipt_id.as_deref(),
1114 Some("attempt-family-1")
1115 );
1116
1117 let reparsed: semantic_memory_forge::ForgeToolReceiptV2 =
1118 serde_json::from_str(&serde_json::to_string(&forge_receipt).unwrap()).unwrap();
1119 assert_eq!(reparsed.receipt_id, forge_receipt.receipt_id);
1120 assert_eq!(reparsed.trace_ctx, forge_receipt.trace_ctx);
1121 assert_eq!(reparsed.raw_payload["payload_kind"], "echo");
1122 }
1123
1124 #[tokio::test]
1125 async fn runtime_requires_execution_permit_for_write_tools() {
1126 let mut registry = ToolRegistry::new();
1127 let mut descriptor = echo_descriptor();
1128 descriptor.name = "submit_patch".into();
1129 descriptor.read_only = false;
1130 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1131 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1132 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1133 descriptor.input_schema = json!({
1134 "type": "object",
1135 "required": ["target", "message"],
1136 "properties": {
1137 "target": {"type": "string"},
1138 "message": {"type": "string"}
1139 },
1140 "additionalProperties": false
1141 });
1142 registry.register(EchoTool { descriptor });
1143 let runtime = ToolRuntime::new(registry);
1144 let call = ToolCall::new(
1145 "submit_patch",
1146 "1.0.0",
1147 json!({"target": "src/lib.rs", "message": "hello"}),
1148 ToolOriginKind::Test,
1149 );
1150
1151 let execution = runtime.execute(&tool_ctx(), &call, None, None).await;
1152 let error = execution.result.unwrap_err();
1153
1154 assert_eq!(error.class, ToolErrorClass::ApprovalRequired);
1155 }
1156
1157 #[tokio::test]
1158 async fn runtime_requires_typed_grant_for_write_tools_even_with_permit() {
1159 let mut registry = ToolRegistry::new();
1160 let mut descriptor = echo_descriptor();
1161 descriptor.name = "submit_patch".into();
1162 descriptor.read_only = false;
1163 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1164 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1165 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1166 descriptor.input_schema = json!({
1167 "type": "object",
1168 "required": ["target", "message"],
1169 "properties": {
1170 "target": {"type": "string"},
1171 "message": {"type": "string"}
1172 },
1173 "additionalProperties": false
1174 });
1175 registry.register(EchoTool { descriptor });
1176 let runtime = ToolRuntime::new(registry);
1177 let call = ToolCall::new(
1178 "submit_patch",
1179 "1.0.0",
1180 json!({"target": "src/lib.rs", "message": "hello"}),
1181 ToolOriginKind::Test,
1182 );
1183 let mut ctx = tool_ctx();
1184 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1185 let permit = execution_permit_for("src/lib.rs");
1186
1187 let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1188 let error = execution.result.unwrap_err();
1189
1190 assert_eq!(error.class, ToolErrorClass::ApprovalRequired);
1191 }
1192
1193 #[tokio::test]
1194 async fn runtime_rejects_out_of_scope_execution_permit_for_write_tools() {
1195 let mut registry = ToolRegistry::new();
1196 let mut descriptor = echo_descriptor();
1197 descriptor.name = "submit_patch".into();
1198 descriptor.read_only = false;
1199 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1200 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1201 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1202 descriptor.input_schema = json!({
1203 "type": "object",
1204 "required": ["target", "message"],
1205 "properties": {
1206 "target": {"type": "string"},
1207 "message": {"type": "string"}
1208 },
1209 "additionalProperties": false
1210 });
1211 registry.register(EchoTool { descriptor });
1212 let runtime = ToolRuntime::new(registry);
1213 let call = ToolCall::new(
1214 "submit_patch",
1215 "1.0.0",
1216 json!({"target": "src/lib.rs", "message": "hello"}),
1217 ToolOriginKind::Test,
1218 );
1219 let mut ctx = tool_ctx();
1220 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1221 let permit = execution_permit_for("other.rs");
1222
1223 let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1224 let error = execution.result.unwrap_err();
1225
1226 assert_eq!(error.class, ToolErrorClass::Denied);
1227 }
1228
1229 #[tokio::test]
1230 async fn runtime_rejects_out_of_scope_grant_for_write_tools() {
1231 let mut registry = ToolRegistry::new();
1232 let mut descriptor = echo_descriptor();
1233 descriptor.name = "submit_patch".into();
1234 descriptor.read_only = false;
1235 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1236 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1237 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1238 descriptor.input_schema = json!({
1239 "type": "object",
1240 "required": ["target", "message"],
1241 "properties": {
1242 "target": {"type": "string"},
1243 "message": {"type": "string"}
1244 },
1245 "additionalProperties": false
1246 });
1247 registry.register(EchoTool { descriptor });
1248 let runtime = ToolRuntime::new(registry);
1249 let call = ToolCall::new(
1250 "submit_patch",
1251 "1.0.0",
1252 json!({"target": "src/lib.rs", "message": "hello"}),
1253 ToolOriginKind::Test,
1254 );
1255 let mut ctx = tool_ctx();
1256 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1257 ctx.approval_grant = Some(approval_grant_for(
1258 "submit_patch",
1259 crate::ApprovalGrantEffectClass::Write,
1260 "runtime-tests",
1261 Some("other.rs"),
1262 ));
1263 let permit = execution_permit_for("src/lib.rs");
1264
1265 let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1266 let error = execution.result.unwrap_err();
1267
1268 assert_eq!(error.class, ToolErrorClass::Denied);
1269 }
1270
1271 #[tokio::test]
1272 async fn runtime_rejects_expired_grant_for_write_tools() {
1273 let mut registry = ToolRegistry::new();
1274 let mut descriptor = echo_descriptor();
1275 descriptor.name = "submit_patch".into();
1276 descriptor.read_only = false;
1277 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1278 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1279 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1280 descriptor.input_schema = json!({
1281 "type": "object",
1282 "required": ["target", "message"],
1283 "properties": {
1284 "target": {"type": "string"},
1285 "message": {"type": "string"}
1286 },
1287 "additionalProperties": false
1288 });
1289 registry.register(EchoTool { descriptor });
1290 let runtime = ToolRuntime::new(registry);
1291 let call = ToolCall::new(
1292 "submit_patch",
1293 "1.0.0",
1294 json!({"target": "src/lib.rs", "message": "hello"}),
1295 ToolOriginKind::Test,
1296 );
1297 let mut ctx = tool_ctx();
1298 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1299 let mut grant = approval_grant_for(
1300 "submit_patch",
1301 crate::ApprovalGrantEffectClass::Write,
1302 "runtime-tests",
1303 Some("src/lib.rs"),
1304 );
1305 grant.expires_at = Some("2000-01-01T00:00:00Z".into());
1306 ctx.approval_grant = Some(grant);
1307 let permit = execution_permit_for("src/lib.rs");
1308
1309 let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1310 let error = execution.result.unwrap_err();
1311
1312 assert_eq!(error.class, ToolErrorClass::Denied);
1313 }
1314
1315 #[tokio::test]
1316 async fn runtime_rejects_mismatched_effect_grant_for_write_tools() {
1317 let mut registry = ToolRegistry::new();
1318 let mut descriptor = echo_descriptor();
1319 descriptor.name = "submit_patch".into();
1320 descriptor.read_only = false;
1321 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1322 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1323 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1324 descriptor.input_schema = json!({
1325 "type": "object",
1326 "required": ["target", "message"],
1327 "properties": {
1328 "target": {"type": "string"},
1329 "message": {"type": "string"}
1330 },
1331 "additionalProperties": false
1332 });
1333 registry.register(EchoTool { descriptor });
1334 let runtime = ToolRuntime::new(registry);
1335 let call = ToolCall::new(
1336 "submit_patch",
1337 "1.0.0",
1338 json!({"target": "src/lib.rs", "message": "hello"}),
1339 ToolOriginKind::Test,
1340 );
1341 let mut ctx = tool_ctx();
1342 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1343 ctx.approval_grant = Some(approval_grant_for(
1344 "submit_patch",
1345 crate::ApprovalGrantEffectClass::Analysis,
1346 "runtime-tests",
1347 Some("src/lib.rs"),
1348 ));
1349 let permit = execution_permit_for("src/lib.rs");
1350
1351 let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1352 let error = execution.result.unwrap_err();
1353
1354 assert_eq!(error.class, ToolErrorClass::Denied);
1355 }
1356
1357 #[tokio::test]
1358 async fn runtime_accepts_matching_typed_grant_and_permit_for_write_tools() {
1359 let mut registry = ToolRegistry::new();
1360 let mut descriptor = echo_descriptor();
1361 descriptor.name = "submit_patch".into();
1362 descriptor.read_only = false;
1363 descriptor.side_effect_class = crate::ToolSideEffectClass::Write;
1364 descriptor.approval_kind = ToolApprovalKind::UserRequired;
1365 descriptor.receipt_persistence = ToolReceiptPersistence::Ephemeral;
1366 descriptor.input_schema = json!({
1367 "type": "object",
1368 "required": ["target", "message"],
1369 "properties": {
1370 "target": {"type": "string"},
1371 "message": {"type": "string"}
1372 },
1373 "additionalProperties": false
1374 });
1375 registry.register(EchoTool { descriptor });
1376 let runtime = ToolRuntime::new(registry);
1377 let call = ToolCall::new(
1378 "submit_patch",
1379 "1.0.0",
1380 json!({"target": "src/lib.rs", "message": "hello"}),
1381 ToolOriginKind::Test,
1382 );
1383 let mut ctx = tool_ctx();
1384 ctx.scope = Some(stack_ids::ScopeKey::namespace_only("runtime-tests"));
1385 ctx.approval_grant = Some(approval_grant_for(
1386 "submit_patch",
1387 crate::ApprovalGrantEffectClass::Write,
1388 "runtime-tests",
1389 Some("src/lib.rs"),
1390 ));
1391 let permit = execution_permit_for("src/lib.rs");
1392
1393 let execution = runtime.execute(&ctx, &call, Some(&permit), None).await;
1394 assert!(execution.result.is_ok());
1395 assert_eq!(
1396 execution.receipt.approval_state,
1397 ToolApprovalState::Approved
1398 );
1399 }
1400}