1use std::{path::PathBuf, sync::Arc};
2
3use async_trait::async_trait;
4use cookie_agent_protocol::{
5 AdaptorId, AgentId, ModelCapabilities, ModelKey, PersistedToolResult as ToolResult,
6 PreparedOperationIdentity, RunId, SessionId, Sha256Digest, ToolAttachment, ToolCallId,
7 ToolCallPresentation,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use thiserror::Error;
12use tokio::sync::mpsc;
13use tokio_util::sync::CancellationToken;
14
15use crate::{
16 events::OutputHub,
17 runtime::tool_execution::validate_attachment,
18 runtime::{ArtifactRouter, OutputCapture, ToolCallFailureCode},
19};
20
21#[derive(Clone, Debug, Deserialize, Serialize)]
22pub struct SessionToolContext {
23 pub session: SessionId,
24 #[serde(skip)]
25 prompt_delegate_targets: Option<Vec<(AgentId, String)>>,
26}
27
28impl SessionToolContext {
29 #[must_use]
30 pub const fn new(session: SessionId) -> Self {
31 Self {
32 session,
33 prompt_delegate_targets: None,
34 }
35 }
36
37 pub(crate) fn for_prompt_composition(
38 session: SessionId,
39 delegate_targets: Vec<(AgentId, String)>,
40 ) -> Self {
41 Self {
42 session,
43 prompt_delegate_targets: Some(delegate_targets),
44 }
45 }
46
47 pub fn prompt_delegate_targets(
49 &self,
50 ) -> Option<impl ExactSizeIterator<Item = (&AgentId, &str)>> {
51 self.prompt_delegate_targets.as_ref().map(|targets| {
52 targets
53 .iter()
54 .map(|(id, description)| (id, description.as_str()))
55 })
56 }
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct PromptSection {
62 pub title: String,
64 pub body: String,
66}
67#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
68pub struct ToolSpec {
69 pub name: String,
70 pub permission_name: String,
71 pub description: String,
72 pub parameters: Value,
73 #[serde(default)]
74 pub concurrency: ToolConcurrency,
75 #[serde(default)]
76 pub result_truncation: ToolResultTruncationPolicy,
77 #[serde(default)]
78 pub output: cookie_agent_protocol::ToolOutputDeclaration,
79}
80
81#[derive(Clone, Debug)]
83pub struct ToolCompletion {
84 pub output: cookie_agent_protocol::ToolCompletionOutput,
85 pub result: ToolResult,
86 pub failed: bool,
87}
88
89impl ToolCompletion {
90 #[cfg(any(test, feature = "test-support"))]
91 pub fn into_result_for_test(self) -> Result<ToolResult, ToolError> {
92 let mut result = self.result;
93 match self.output {
94 cookie_agent_protocol::ToolCompletionOutput::Single { text } => result.output = text,
95 _ => {
96 return Err(ToolError::execution(
97 "streamed tests must use runtime capture",
98 ));
99 }
100 }
101 Ok(result)
102 }
103
104 pub fn single(mut result: ToolResult) -> Self {
105 let text = std::mem::take(&mut result.output);
106 if result.display.is_none() {
107 result.display = Some(bounded_tool_display(&text));
108 }
109 Self {
110 output: cookie_agent_protocol::ToolCompletionOutput::Single { text },
111 result,
112 failed: false,
113 }
114 }
115
116 pub fn streamed(mut result: ToolResult) -> Self {
117 if result.display.is_none() {
118 result.display = Some(String::new());
119 }
120 Self {
121 output: cookie_agent_protocol::ToolCompletionOutput::Streamed,
122 result,
123 failed: false,
124 }
125 }
126}
127
128pub(crate) fn bounded_tool_display(text: &str) -> String {
129 sanitize_tool_display(text, cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES)
130}
131
132pub(crate) fn sanitize_tool_display(text: &str, maximum: usize) -> String {
133 let mut display = String::new();
134 for character in text.chars() {
135 let character = if character.is_control() && !matches!(character, '\n' | '\t') {
136 ' '
137 } else {
138 character
139 };
140 if display.len() + character.len_utf8() > maximum {
141 break;
142 }
143 display.push(character);
144 }
145 display
146}
147
148#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ToolConcurrency {
152 #[default]
153 Exclusive,
154 Parallel,
155}
156
157#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ToolResultTruncationPolicy {
160 #[default]
161 Bounded,
162 OptOut,
163}
164
165pub(crate) const UNSCOPED_PERMISSION_RESOURCE_DISPLAY: &str = "<permission-name-only>";
166#[derive(Clone, Debug, Deserialize, Serialize)]
167pub struct ToolCall {
168 pub id: ToolCallId,
169 pub name: String,
170 pub arguments: Value,
171}
172#[derive(Clone, Debug, Deserialize, Serialize)]
173pub struct ToolProgress {
174 pub tool_call_id: ToolCallId,
175 pub message: String,
176 pub display: Option<String>,
177 #[serde(default)]
178 pub output: Vec<cookie_agent_protocol::ToolOutputChunk>,
179}
180
181#[derive(Clone, Debug)]
182pub struct ProgressSink {
183 sender: mpsc::Sender<ToolProgress>,
184 output: OutputHub,
185 capture: Option<OutputCapture>,
186 display_used: Arc<std::sync::Mutex<usize>>,
187}
188impl ProgressSink {
189 #[cfg(feature = "test-support")]
190 pub async fn for_test(
191 sender: mpsc::Sender<ToolProgress>,
192 output: OutputHub,
193 directory: PathBuf,
194 declaration: cookie_agent_protocol::ToolOutputDeclaration,
195 ) -> Result<Self, ToolError> {
196 let router = ArtifactRouter::open_flat(directory)
197 .map_err(|e| ToolError::execution(e.to_string()))?;
198 let capture =
199 OutputCapture::new(router, SessionId::new_v7(), declaration, 2_000, 16 * 1024).await?;
200 Ok(Self::with_capture(sender, output, capture))
201 }
202
203 #[must_use]
204 pub fn new(sender: mpsc::Sender<ToolProgress>, output: OutputHub) -> Self {
205 Self {
206 sender,
207 output,
208 capture: None,
209 display_used: Arc::new(std::sync::Mutex::new(0)),
210 }
211 }
212 pub(crate) fn with_capture(
213 sender: mpsc::Sender<ToolProgress>,
214 output: OutputHub,
215 capture: OutputCapture,
216 ) -> Self {
217 Self {
218 sender,
219 output,
220 capture: Some(capture),
221 display_used: Arc::new(std::sync::Mutex::new(0)),
222 }
223 }
224 pub async fn send(&self, mut progress: ToolProgress) -> Result<(), ToolError> {
225 if !progress.output.is_empty() && self.capture.is_none() {
226 return Err(ToolError::execution("output capture is unavailable"));
227 }
228 if progress.message.len() > cookie_agent_protocol::SafeDisplayText::MAX_BYTES
229 || progress.display.as_ref().is_some_and(|display| {
230 display.len() > cookie_agent_protocol::SafeDisplayText::MAX_BYTES
231 })
232 {
233 return Err(ToolError::resource_limit(
234 "tool display delta exceeds the display event bound",
235 ));
236 }
237 if progress.output.len() > cookie_agent_protocol::MAX_TOOL_STREAMS
238 || progress.output.iter().fold(0_usize, |total, chunk| {
239 total.saturating_add(chunk.text.len())
240 }) > cookie_agent_protocol::MAX_TOOL_DELTA_BYTES
241 {
242 return Err(ToolError::resource_limit(
243 "tool output delta exceeds 64 KiB or 8 chunks",
244 ));
245 }
246 for chunk in &progress.output {
247 if let Some(name) = &chunk.stream {
248 cookie_agent_protocol::validate_tool_stream_name(name)
249 .map_err(ToolError::execution)?;
250 }
251 }
252 {
253 let mut used = self.display_used.lock().unwrap_or_else(|p| p.into_inner());
254 let remaining = cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES.saturating_sub(*used);
255 progress.message = sanitize_tool_display(&progress.message, remaining);
256 *used += progress.message.len();
257 progress.display = progress
258 .display
259 .as_deref()
260 .map(|text| {
261 sanitize_tool_display(
262 text,
263 cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES.saturating_sub(*used),
264 )
265 })
266 .filter(|text| !text.is_empty());
267 *used += progress.display.as_ref().map_or(0, String::len);
268 }
269 let permit = if progress.message.is_empty() && progress.display.is_none() {
270 None
271 } else {
272 Some(
273 self.sender
274 .clone()
275 .reserve_owned()
276 .await
277 .map_err(|_| ToolError::ProgressSinkClosed)?,
278 )
279 };
280 if let Some(capture) = &self.capture {
281 return capture.emit(progress, self.output.clone(), permit).await;
282 }
283 if let Some(permit) = permit {
284 permit.send(progress);
285 }
286 Ok(())
287 }
288}
289
290#[derive(Debug)]
291pub struct ToolStdin {
292 receiver: mpsc::Receiver<StdinWrite>,
293}
294impl ToolStdin {
295 #[must_use]
298 pub fn channel(capacity: usize) -> (mpsc::Sender<StdinWrite>, Self) {
299 let (sender, receiver) = mpsc::channel(capacity);
300 (sender, Self { receiver })
301 }
302
303 #[must_use]
304 pub fn from_receiver(receiver: mpsc::Receiver<StdinWrite>) -> Self {
305 Self { receiver }
306 }
307
308 pub async fn recv(&mut self) -> Option<StdinWrite> {
309 self.receiver.recv().await
310 }
311}
312#[derive(Clone, Debug)]
313pub struct StdinWrite {
314 pub data: Vec<u8>,
315 pub eof: bool,
316}
317
318#[derive(Debug)]
324pub struct TurnAgentContext {
325 pub agent: AgentId,
327 pub model: ModelKey,
329 pub adapter: AdaptorId,
331 pub adapter_family: cookie_agent_models::adapters::OvenAdapterFamily,
333 pub capabilities: ModelCapabilities,
335}
336
337#[derive(Clone, Debug)]
338pub struct ToolPreparationContext {
339 pub session: SessionId,
340 pub run: RunId,
341 pub cwd: PathBuf,
342 pub workspace_root: PathBuf,
343 pub turn_context: Arc<TurnAgentContext>,
345}
346
347#[derive(Debug)]
348pub struct ToolExecutionContext {
349 pub session: SessionId,
350 pub run: RunId,
351 pub progress: ProgressSink,
352 pub cancellation: CancellationToken,
353 pub stdin: Option<ToolStdin>,
354 pub turn_context: Arc<TurnAgentContext>,
356 pub(crate) artifacts: Arc<ArtifactRouter>,
357}
358
359impl ToolExecutionContext {
360 pub async fn read_artifact(
361 &self,
362 path: &str,
363 offset: u64,
364 limit: u64,
365 ) -> Result<crate::ArtifactReadPage, ToolError> {
366 crate::runtime::read_artifact_async(self.artifacts.clone(), path, offset, limit).await
367 }
368
369 #[cfg(feature = "test-support")]
370 pub fn for_test(
371 artifact_directory: impl Into<PathBuf>,
372 turn_context: Arc<TurnAgentContext>,
373 ) -> Result<Self, ToolError> {
374 let call_id = ToolCallId::new_v7();
375 let (progress, _receiver) = mpsc::channel(1);
376 Ok(Self {
377 session: SessionId::new_v7(),
378 run: RunId::new_v7(),
379 progress: ProgressSink::new(progress, OutputHub::new(call_id, 1024)),
380 cancellation: CancellationToken::new(),
381 stdin: None,
382 turn_context,
383 artifacts: ArtifactRouter::open_flat(artifact_directory.into())
384 .map_err(|error| ToolError::execution(error.to_string()))?,
385 })
386 }
387
388 pub fn retain_attachment(
389 &self,
390 mime_type: impl Into<String>,
391 filename: Option<String>,
392 bytes: &[u8],
393 ) -> Result<ToolAttachment, ToolError> {
394 let mime_type = mime_type.into();
395 let path = filename.as_deref().map_or_else(PathBuf::new, PathBuf::from);
396 validate_attachment(&mime_type, &path, bytes)?;
397 self.retain_validated_attachment(mime_type, filename, bytes)
398 }
399
400 pub fn retain_validated_attachment(
401 &self,
402 mime_type: impl Into<String>,
403 filename: Option<String>,
404 bytes: &[u8],
405 ) -> Result<ToolAttachment, ToolError> {
406 let mime_type = mime_type.into();
407 let (reference, sha256) = self
408 .artifacts
409 .retain(self.session, bytes)
410 .map_err(|error| ToolError::execution(error.to_string()))?;
411 Ok(ToolAttachment {
412 mime_type: cookie_agent_protocol::MimeType::new(mime_type)
413 .map_err(|error| ToolError::execution(error.to_string()))?,
414 filename,
415 byte_length: bytes.len() as u64,
416 sha256: Sha256Digest::new(sha256)
417 .map_err(|error| ToolError::execution(error.to_string()))?,
418 reference,
419 })
420 }
421}
422#[derive(Debug, Error)]
423pub enum ToolError {
424 #[error("tool progress sink closed")]
425 ProgressSinkClosed,
426 #[error("tool failed: {0}")]
427 Failed(String),
428 #[error("prepared operation changed: {0}")]
429 OperationChanged(String),
430 #[error("unsupported prepared-operation security: {0}")]
431 UnsupportedSecurity(String),
432 #[error("prepared operation is unsupported on this platform: {0}")]
433 UnsupportedPlatform(String),
434 #[error("prepared capability resource limit exceeded: {0}")]
435 ResourceLimit(String),
436 #[error("invalid URL: {0}")]
437 InvalidUrl(String),
438 #[error("permission denied: {0}")]
439 PermissionDenied(String),
440 #[error("redirect error: {0}")]
441 RedirectError(String),
442 #[error("request timed out: {0}")]
443 Timeout(String),
444 #[error("transport error: {0}")]
445 TransportError(String),
446 #[error("unsupported content type: {0}")]
447 UnsupportedContentType(String),
448}
449
450impl ToolError {
451 #[must_use]
452 pub fn operation_changed(message: impl Into<String>) -> Self {
453 Self::OperationChanged(message.into())
454 }
455
456 #[must_use]
457 pub fn unsupported_security(message: impl Into<String>) -> Self {
458 Self::UnsupportedSecurity(message.into())
459 }
460
461 #[must_use]
462 pub fn unsupported_platform(message: impl Into<String>) -> Self {
463 Self::UnsupportedPlatform(message.into())
464 }
465
466 #[must_use]
467 pub fn resource_limit(message: impl Into<String>) -> Self {
468 Self::ResourceLimit(message.into())
469 }
470
471 #[must_use]
472 pub fn execution(message: impl Into<String>) -> Self {
473 Self::Failed(cookie_agent_protocol::diagnostics::detail(&message.into()).to_string())
474 }
475
476 #[must_use]
477 pub(crate) const fn code(&self) -> ToolCallFailureCode {
478 match self {
479 Self::ProgressSinkClosed | Self::Failed(_) => ToolCallFailureCode::ExecutionFailed,
480 Self::OperationChanged(_) => ToolCallFailureCode::OperationChanged,
481 Self::UnsupportedSecurity(_) | Self::ResourceLimit(_) => {
482 ToolCallFailureCode::ExecutionFailed
483 }
484 Self::UnsupportedPlatform(_) => ToolCallFailureCode::UnsupportedPlatform,
485 Self::InvalidUrl(_) => ToolCallFailureCode::InvalidUrl,
486 Self::PermissionDenied(_) => ToolCallFailureCode::PermissionDenied,
487 Self::RedirectError(_) => ToolCallFailureCode::RedirectError,
488 Self::Timeout(_) => ToolCallFailureCode::Timeout,
489 Self::TransportError(_) => ToolCallFailureCode::TransportError,
490 Self::UnsupportedContentType(_) => ToolCallFailureCode::UnsupportedContentType,
491 }
492 }
493
494 #[must_use]
495 pub fn message(&self) -> String {
496 match self {
497 Self::ProgressSinkClosed => "tool progress sink closed".into(),
498 Self::Failed(message)
499 | Self::OperationChanged(message)
500 | Self::UnsupportedSecurity(message)
501 | Self::UnsupportedPlatform(message)
502 | Self::InvalidUrl(message)
503 | Self::PermissionDenied(message)
504 | Self::RedirectError(message)
505 | Self::Timeout(message)
506 | Self::TransportError(message)
507 | Self::UnsupportedContentType(message)
508 | Self::ResourceLimit(message) => message.clone(),
509 }
510 }
511}
512
513#[derive(Clone, Debug, Eq, Hash, PartialEq)]
514pub struct PreparedSerializationKey(Vec<u8>);
515
516impl PreparedSerializationKey {
517 #[must_use]
518 pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
519 Self(bytes.into())
520 }
521}
522
523#[async_trait]
524pub trait PreparedExecutor: Send + Sync {
525 async fn revalidate(&self) -> Result<(), ToolError>;
526
527 async fn revalidate_for_execution(&self) -> Result<(), ToolError> {
530 self.revalidate().await
531 }
532
533 async fn execute(
534 self: Box<Self>,
535 context: ToolExecutionContext,
536 ) -> Result<crate::ToolCompletion, ToolError>;
537}
538
539pub struct PreparedTool {
540 pub(crate) operation: PreparedOperationIdentity,
541 pub(crate) policy_labels: Vec<Option<String>>,
542 pub(crate) normalized_arguments: serde_json::Value,
543 pub(crate) serialization_key: Option<PreparedSerializationKey>,
544 pub(crate) executor: PreparedExecutorCell,
545}
546
547pub(crate) type PreparedExecutorCell = Arc<tokio::sync::Mutex<Option<Box<dyn PreparedExecutor>>>>;
548
549impl PreparedTool {
550 pub fn new(
551 operation: PreparedOperationIdentity,
552 normalized_arguments: serde_json::Value,
553 serialization_key: Option<PreparedSerializationKey>,
554 executor: Box<dyn PreparedExecutor>,
555 ) -> Result<Self, ToolError> {
556 if operation.resources().is_empty() {
557 return Err(ToolError::execution(
558 "prepared tool requires at least one permission resource",
559 ));
560 }
561 if normalized_arguments.is_null() {
562 return Err(ToolError::execution(
563 "prepared normalized arguments must not be null",
564 ));
565 }
566 let policy_labels = operation
567 .resources()
568 .iter()
569 .map(|resource| Some(resource.canonical.as_str().to_owned()))
570 .collect();
571 Ok(Self {
572 operation,
573 policy_labels,
574 normalized_arguments,
575 serialization_key,
576 executor: Arc::new(tokio::sync::Mutex::new(Some(executor))),
577 })
578 }
579
580 #[must_use]
581 pub const fn operation(&self) -> &PreparedOperationIdentity {
582 &self.operation
583 }
584
585 pub fn with_policy_labels(mut self, labels: Vec<String>) -> Result<Self, ToolError> {
586 if labels.is_empty() || labels.len() != self.operation.resources().len() {
587 return Err(ToolError::execution(
588 "prepared policy labels must cover every resource",
589 ));
590 }
591 for (resource, label) in self.operation.resources().iter().zip(&labels) {
592 let expected = Sha256Digest::of_bytes(label.as_bytes());
593 if resource
594 .canonical
595 .as_str()
596 .rsplit_once(':')
597 .is_none_or(|(_, digest)| digest != expected.as_str())
598 {
599 return Err(ToolError::execution(
600 "prepared policy label does not match its immutable resource identity",
601 ));
602 }
603 }
604 self.policy_labels = labels.into_iter().map(Some).collect();
605 Ok(self)
606 }
607
608 pub fn with_permission_resource(mut self, resource: Option<String>) -> Result<Self, ToolError> {
609 if resource.as_ref().is_some_and(String::is_empty) {
610 return Err(ToolError::execution(
611 "permission resource must not be empty",
612 ));
613 }
614 self.policy_labels.fill(resource);
615 Ok(self)
616 }
617
618 #[must_use]
619 pub const fn normalized_arguments(&self) -> &serde_json::Value {
620 &self.normalized_arguments
621 }
622
623 #[must_use]
624 pub fn policy_labels(&self) -> &[Option<String>] {
625 &self.policy_labels
626 }
627
628 #[cfg(feature = "test-support")]
629 pub async fn execute_for_test(
630 self,
631 context: ToolExecutionContext,
632 ) -> Result<ToolResult, ToolError> {
633 let executor = self
634 .executor
635 .lock()
636 .await
637 .take()
638 .ok_or_else(|| ToolError::execution("prepared executor was already consumed"))?;
639 executor.execute(context).await?.into_result_for_test()
640 }
641
642 #[cfg(feature = "test-support")]
643 pub async fn revalidate_for_execution_for_test(&self) -> Result<(), ToolError> {
644 let executor = self.executor.lock().await;
645 executor
646 .as_ref()
647 .ok_or_else(|| ToolError::execution("prepared executor was already consumed"))?
648 .revalidate_for_execution()
649 .await
650 }
651}
652
653#[async_trait]
654pub trait ToolProvider: Send + Sync {
655 fn provider_id(&self) -> &'static str;
657
658 fn prompt_sections(&self, _ctx: &SessionToolContext) -> Result<Vec<PromptSection>, ToolError> {
660 Ok(Vec::new())
661 }
662
663 fn tools_for_session(&self, ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError>;
664 fn permission_for_unlisted_tool(
666 &self,
667 _tool_name: &str,
668 ) -> Result<Option<&'static str>, ToolError> {
669 Ok(None)
670 }
671 fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError>
672 where
673 Self: Sized;
674 fn get_permission_resource(
675 &self,
676 tool_name: &str,
677 arguments: &Value,
678 ) -> Result<(&'static str, Option<String>), ToolError>;
679 fn get_display_argument(&self, name: &str, arguments: &Value) -> Result<String, ToolError>;
680
681 fn presentation(&self, call: &ToolCall) -> ToolCallPresentation {
682 match self.get_display_argument(&call.name, &call.arguments) {
683 Ok(display) => crate::runtime::tool_execution::tool_presentation(&call.name, &display),
684 Err(_) => crate::runtime::tool_execution::tool_title_only(&call.name),
685 }
686 }
687 async fn prepare(
688 &self,
689 ctx: ToolPreparationContext,
690 call: ToolCall,
691 ) -> Result<PreparedTool, ToolError>;
692
693 async fn prepare_parallel(
696 &self,
697 ctx: ToolPreparationContext,
698 calls: Vec<ToolCall>,
699 ) -> Vec<Result<PreparedTool, ToolError>> {
700 let mut prepared = Vec::with_capacity(calls.len());
701 for call in calls {
702 prepared.push(self.prepare(ctx.clone(), call).await);
703 }
704 prepared
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use cookie_agent_protocol::{
711 PersistedToolResult as ToolResult, PreparedOperationIdentity, Sha256Digest,
712 };
713
714 use super::{
715 PreparedExecutor, PreparedTool, ToolConcurrency, ToolError, ToolExecutionContext, ToolSpec,
716 async_trait,
717 };
718
719 struct NoopExecutor;
720
721 #[async_trait]
722 impl PreparedExecutor for NoopExecutor {
723 async fn revalidate(&self) -> Result<(), ToolError> {
724 Ok(())
725 }
726
727 async fn execute(
728 self: Box<Self>,
729 _context: ToolExecutionContext,
730 ) -> Result<crate::ToolCompletion, ToolError> {
731 let result: Result<ToolResult, ToolError> =
732 async move { unreachable!("constructor validation test never executes") }.await;
733 result.map(crate::ToolCompletion::single)
734 }
735 }
736
737 fn operation() -> PreparedOperationIdentity {
738 let label = "command:test";
739 PreparedOperationIdentity::new(
740 Sha256Digest::of_bytes(b"arguments"),
741 vec![cookie_agent_protocol::ApprovalCapability {
742 action: cookie_agent_protocol::PermissionAction::Bash,
743 operation: cookie_agent_protocol::PreparedCapabilityOperation::new("bash:execute")
744 .expect("capability operation"),
745 }],
746 vec![cookie_agent_protocol::PreparedApprovalResource {
747 capability: cookie_agent_protocol::PermissionAction::Bash,
748 canonical: cookie_agent_protocol::PreparedResourceIdentity::new(format!(
749 "command:{}",
750 Sha256Digest::of_bytes(label.as_bytes())
751 ))
752 .expect("resource identity"),
753 binding_digest:
754 cookie_agent_protocol::PreparedResourceDigest::from_canonical_binding_bytes(
755 label.as_bytes(),
756 ),
757 binding_lifetime: cookie_agent_protocol::PreparedBindingLifetime::ProcessLocal,
758 boundary: cookie_agent_protocol::ApprovalBoundary::Exact,
759 source: cookie_agent_protocol::ApprovalResourceSource::PrimaryOperation,
760 }],
761 Sha256Digest::of_bytes(b"context"),
762 )
763 .expect("prepared operation")
764 }
765
766 #[test]
767 fn prepared_tool_rejects_null_normalized_arguments() {
768 let error = match PreparedTool::new(
769 operation(),
770 serde_json::Value::Null,
771 None,
772 Box::new(NoopExecutor),
773 ) {
774 Ok(_) => panic!("null normalized arguments must fail"),
775 Err(error) => error,
776 };
777 assert!(error.to_string().contains("must not be null"));
778 }
779
780 #[test]
781 fn tool_concurrency_defaults_to_exclusive_when_omitted() {
782 let spec: ToolSpec = serde_json::from_value(serde_json::json!({
783 "name":"external_tool",
784 "permission_name":"external",
785 "description":"External tool",
786 "parameters":{"type":"object"}
787 }))
788 .expect("tool spec without concurrency");
789 assert_eq!(spec.concurrency, ToolConcurrency::Exclusive);
790 }
791
792 #[test]
793 fn absent_permission_resource_sets_the_loose_policy_marker() {
794 let prepared = PreparedTool::new(
795 operation(),
796 serde_json::json!({}),
797 None,
798 Box::new(NoopExecutor),
799 )
800 .expect("prepared tool")
801 .with_permission_resource(None)
802 .expect("loose permission resource");
803 assert_eq!(prepared.policy_labels(), [None]);
804 }
805}