Skip to main content

cookie_agent_tools/
lib.rs

1//! Exact cookie-agent protocol 20 prepared built-in tools.
2
3use std::path::{Path, PathBuf};
4
5use async_trait::async_trait;
6use cookie_agent_engine::{
7    PreparedTool, SessionToolContext, ToolCall, ToolError, ToolPreparationContext, ToolProvider,
8    ToolSpec,
9};
10use cookie_agent_protocol::{
11    ApprovalBoundary, ApprovalCapability, ApprovalResourceSource, PermissionAction,
12    PreparedApprovalResource, PreparedBindingLifetime, PreparedCapabilityOperation,
13    PreparedOperationIdentity, PreparedResourceDigest, PreparedResourceIdentity, Sha256Digest,
14};
15use schemars::JsonSchema;
16use serde::{Serialize, de::DeserializeOwned};
17
18pub mod bash;
19pub mod delegate;
20pub mod edit;
21pub mod fs_cap;
22pub mod goal;
23pub mod message;
24mod path_args;
25pub mod read;
26pub mod skill;
27pub mod webfetch;
28pub mod write;
29
30#[cfg(test)]
31pub(crate) fn test_turn_context() -> std::sync::Arc<cookie_agent_engine::TurnAgentContext> {
32    std::sync::Arc::new(cookie_agent_engine::TurnAgentContext {
33        agent: cookie_agent_protocol::AgentId::new("test").expect("test agent ID"),
34        model: "test/model".parse().expect("test model key"),
35        adapter: cookie_agent_protocol::AdaptorId::OpenaiChat,
36        adapter_family: cookie_agent_models::adapters::OvenAdapterFamily::OpenaiChat,
37        capabilities: cookie_agent_protocol::ModelCapabilities {
38            input: std::collections::BTreeSet::from([cookie_agent_protocol::Modality::Text]),
39            output: std::collections::BTreeSet::from([cookie_agent_protocol::Modality::Text]),
40            context_tokens: 8_192,
41            output_tokens: 2_048,
42            tool_calling: true,
43            parallel_tool_calls: true,
44            structured_output: false,
45            reasoning: false,
46            temperature: true,
47            top_p: true,
48            seed: false,
49            native_replay: cookie_agent_protocol::ReplayCapability::Optional,
50            cancellation: cookie_agent_protocol::CancellationCapability::LocalOnly,
51            media: std::collections::BTreeMap::new(),
52        },
53    })
54}
55
56#[cfg(test)]
57pub(crate) fn assert_workspace_rule_allows(
58    prepared: &cookie_agent_engine::PreparedTool,
59    workspace: &Path,
60    action: PermissionAction,
61    resource: &str,
62) {
63    use cookie_agent_protocol::{
64        AgentDocumentSource, AgentId, AgentMode, AgentSchemaVersion, AgentSnapshot,
65        PermissionEffect, PermissionRule, WildcardPattern,
66    };
67
68    let policy = AgentSnapshot {
69        agent: AgentId::new("test").expect("agent id"),
70        schema: AgentSchemaVersion::current(),
71        mode: AgentMode::Primary,
72        description: "Test agent".into(),
73        document_source: AgentDocumentSource::Workspace,
74        document_fingerprint: Sha256Digest::of_bytes(b"test document"),
75        composed_prompt: "Test permission evaluation.\n".into(),
76        prompt_fingerprint: Sha256Digest::of_bytes(b"Test permission evaluation.\n"),
77        max_output_tokens: 0,
78        permissions: vec![PermissionRule {
79            action,
80            resource: WildcardPattern::new(resource).expect("permission resource pattern"),
81            effect: PermissionEffect::Allow,
82        }],
83        delegation: None,
84        fallback_chain: Vec::new(),
85        selected_suffix_start: 0,
86    };
87    let decision = cookie_agent_engine::permissions::PermissionPipeline::default()
88        .decide_operation(
89            &policy,
90            prepared.operation(),
91            prepared.policy_labels(),
92            workspace,
93        );
94    assert_eq!(decision.effect, PermissionEffect::Allow);
95    assert_eq!(decision.evaluations.len(), 1);
96}
97
98pub(crate) fn schema<T: JsonSchema>() -> serde_json::Value {
99    serde_json::to_value(schemars::schema_for!(T)).expect("tool schemas serialize")
100}
101
102pub(crate) fn tool_error(error: impl std::fmt::Display) -> ToolError {
103    ToolError::execution(error.to_string())
104}
105
106pub(crate) fn safe_title(value: impl AsRef<str>) -> cookie_agent_protocol::SafeDisplayText {
107    let mut safe = String::new();
108    for character in value.as_ref().chars() {
109        let character = if character.is_control() {
110            ' '
111        } else {
112            character
113        };
114        if safe.len() + character.len_utf8() > cookie_agent_protocol::SafeDisplayText::MAX_BYTES {
115            break;
116        }
117        safe.push(character);
118    }
119    cookie_agent_protocol::SafeDisplayText::new(if safe.is_empty() {
120        "Tool result".to_owned()
121    } else {
122        safe
123    })
124    .expect("sanitized tool title")
125}
126
127pub(crate) fn parse_args<T: DeserializeOwned>(
128    tool: &str,
129    value: serde_json::Value,
130) -> Result<T, ToolError> {
131    serde_json::from_value(value).map_err(|error| {
132        tool_error(format!(
133            "The {tool} tool was called with invalid arguments: {error}.\nPlease rewrite the input so it satisfies the expected schema."
134        ))
135    })
136}
137
138pub(crate) fn prepared_operation<T: Serialize>(
139    tool: &str,
140    normalized_arguments: &T,
141    capabilities: Vec<(PermissionAction, &str)>,
142    resources: Vec<PreparedApprovalResource>,
143    execution_context_bytes: &[u8],
144) -> Result<PreparedOperationIdentity, ToolError> {
145    let arguments = serde_json::to_vec(normalized_arguments).map_err(tool_error)?;
146    let capabilities = capabilities
147        .into_iter()
148        .map(|(action, operation)| {
149            Ok(ApprovalCapability {
150                action,
151                operation: PreparedCapabilityOperation::new(format!("{tool}:{operation}"))
152                    .map_err(tool_error)?,
153            })
154        })
155        .collect::<Result<Vec<_>, ToolError>>()?;
156    PreparedOperationIdentity::new(
157        Sha256Digest::of_bytes(&arguments),
158        capabilities,
159        resources,
160        Sha256Digest::of_bytes(execution_context_bytes),
161    )
162    .map_err(tool_error)
163}
164
165pub(crate) fn prepared_resource(
166    action: PermissionAction,
167    logical_kind: &str,
168    stable_label_bytes: &[u8],
169    binding_bytes: &[u8],
170    lifetime: PreparedBindingLifetime,
171    source: ApprovalResourceSource,
172) -> Result<PreparedApprovalResource, ToolError> {
173    let label = Sha256Digest::of_bytes(stable_label_bytes);
174    let mut complete_binding = Vec::new();
175    complete_binding.extend_from_slice(logical_kind.as_bytes());
176    complete_binding.push(0);
177    complete_binding.extend_from_slice(stable_label_bytes);
178    complete_binding.push(0);
179    complete_binding.extend_from_slice(binding_bytes);
180    Ok(PreparedApprovalResource {
181        capability: action,
182        canonical: PreparedResourceIdentity::new(format!("{logical_kind}:{}", label.as_str()))
183            .map_err(tool_error)?,
184        binding_digest: PreparedResourceDigest::from_canonical_binding_bytes(&complete_binding),
185        binding_lifetime: lifetime,
186        boundary: if action == PermissionAction::Bash {
187            ApprovalBoundary::CommandPrefix {
188                prefix: String::from_utf8_lossy(stable_label_bytes).into_owned(),
189            }
190        } else {
191            ApprovalBoundary::Exact
192        },
193        source,
194    })
195}
196
197pub(crate) fn prepared_path_resources(
198    action: PermissionAction,
199    logical_kind: &str,
200    requested_path: &Path,
201    workspace: &Path,
202    binding_bytes: &[u8],
203) -> Result<(Vec<PreparedApprovalResource>, Vec<String>), ToolError> {
204    #[cfg(not(windows))]
205    let workspace = workspace
206        .canonicalize()
207        .unwrap_or_else(|_| workspace.to_owned());
208    #[cfg(windows)]
209    let workspace = workspace.to_owned();
210    // Authorization names the lexical request, never its resolved destination.
211    let label = permission_path_label(&normalized_path(requested_path), &workspace);
212    let resource = prepared_resource(
213        action,
214        logical_kind,
215        label.as_bytes(),
216        binding_bytes,
217        PreparedBindingLifetime::ProcessLocal,
218        ApprovalResourceSource::PrimaryOperation,
219    )?;
220    Ok((vec![resource], vec![label]))
221}
222
223fn normalized_path(path: &Path) -> String {
224    #[cfg(windows)]
225    let path = fs_cap::lexical_path_spelling(path);
226    let value = readable_path(path.to_string_lossy().replace('\\', "/"));
227    if value.is_empty() { ".".into() } else { value }
228}
229
230#[cfg(windows)]
231fn readable_path(value: String) -> String {
232    if let Some(path) = value.strip_prefix("//?/UNC/") {
233        format!("//{path}")
234    } else {
235        value.strip_prefix("//?/").unwrap_or(&value).to_owned()
236    }
237}
238
239#[cfg(not(windows))]
240fn readable_path(value: String) -> String {
241    value
242}
243
244pub(crate) fn permission_path_label(path: &str, workspace: &Path) -> String {
245    let path = normalized_path(Path::new(path));
246    #[cfg(not(windows))]
247    let workspace = workspace
248        .canonicalize()
249        .unwrap_or_else(|_| workspace.to_owned());
250    #[cfg(windows)]
251    let workspace = workspace.to_owned();
252    if let Some(relative) = strip_absolute_prefix(&path, &normalized_path(&workspace)) {
253        return relative;
254    }
255    path
256}
257
258pub(crate) fn abbreviated_display_path(path: &str, workspace: &Path) -> String {
259    let path = normalized_path(Path::new(path));
260    if !Path::new(&path).is_absolute() {
261        return path;
262    }
263    #[cfg(not(windows))]
264    {
265        if let Some(relative) = strip_absolute_prefix(&path, &normalized_path(workspace)) {
266            return relative;
267        }
268        if let Ok(home) = cookie_agent_protocol::paths::home_dir() {
269            let home = normalized_path(&home);
270            if let Some(relative) = strip_absolute_prefix(&path, &home) {
271                return if relative == "." {
272                    "~".into()
273                } else {
274                    format!("~/{relative}")
275                };
276            }
277        }
278        path
279    }
280    #[cfg(windows)]
281    {
282        if let Some(relative) = strip_absolute_prefix(&path, &normalized_path(workspace)) {
283            return relative;
284        }
285        if let Ok(home) = cookie_agent_protocol::paths::home_dir()
286            && let Some(relative) = strip_absolute_prefix(&path, &normalized_path(&home))
287        {
288            return if relative == "." {
289                "~".into()
290            } else {
291                format!("~/{relative}")
292            };
293        }
294        path
295    }
296}
297
298fn strip_absolute_prefix(path: &str, prefix: &str) -> Option<String> {
299    let prefix = prefix.trim_end_matches('/');
300    if prefix.is_empty() {
301        return None;
302    }
303    if path_component_prefix(path, prefix) && path.len() == prefix.len() {
304        return Some(".".into());
305    }
306    path_component_prefix(path, prefix)
307        .then(|| &path[prefix.len()..])
308        .and_then(|rest| rest.strip_prefix('/'))
309        .map(str::to_owned)
310}
311
312#[cfg(windows)]
313fn path_component_prefix(path: &str, prefix: &str) -> bool {
314    path.get(..prefix.len())
315        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
316}
317
318#[cfg(not(windows))]
319fn path_component_prefix(path: &str, prefix: &str) -> bool {
320    path.starts_with(prefix)
321}
322
323#[derive(Debug)]
324pub struct BuiltinTools {
325    read: read::ReadTool,
326    write: write::WriteTool,
327    edit: edit::EditTool,
328    bash: bash::BashTool,
329}
330
331impl BuiltinTools {
332    #[must_use]
333    pub fn new(workspace: impl Into<PathBuf>) -> Self {
334        let workspace = workspace.into();
335        Self {
336            read: read::ReadTool::new(workspace.clone()),
337            write: write::WriteTool::new(workspace.clone()),
338            edit: edit::EditTool::new(workspace.clone()),
339            bash: bash::BashTool::new(workspace),
340        }
341    }
342}
343
344impl Default for BuiltinTools {
345    fn default() -> Self {
346        Self::new(std::env::current_dir().expect("current directory"))
347    }
348}
349
350#[async_trait]
351impl ToolProvider for BuiltinTools {
352    fn provider_id(&self) -> &'static str {
353        "builtin.tools"
354    }
355
356    fn tools_for_session(&self, ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
357        let mut tools = Vec::new();
358        tools.extend(self.read.tools_for_session(ctx)?);
359        tools.extend(self.write.tools_for_session(ctx)?);
360        tools.extend(self.edit.tools_for_session(ctx)?);
361        tools.extend(self.bash.tools_for_session(ctx)?);
362        tools.extend(webfetch::WebfetchTool.tools_for_session(ctx)?);
363        Ok(tools)
364    }
365
366    fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
367        match tool_name {
368            "read" => read::ReadTool::get_permission_name(tool_name),
369            "write" => write::WriteTool::get_permission_name(tool_name),
370            "edit" => edit::EditTool::get_permission_name(tool_name),
371            "bash" => bash::BashTool::get_permission_name(tool_name),
372            "webfetch" => webfetch::WebfetchTool::get_permission_name(tool_name),
373            _ => Err(tool_error(format!("unknown built-in tool `{tool_name}`"))),
374        }
375    }
376
377    fn get_permission_resource(
378        &self,
379        name: &str,
380        arguments: &serde_json::Value,
381    ) -> Result<(&'static str, Option<String>), ToolError> {
382        match name {
383            "read" => self.read.get_permission_resource(name, arguments),
384            "write" => self.write.get_permission_resource(name, arguments),
385            "edit" => self.edit.get_permission_resource(name, arguments),
386            "bash" => self.bash.get_permission_resource(name, arguments),
387            "webfetch" => webfetch::WebfetchTool.get_permission_resource(name, arguments),
388            _ => Err(tool_error(format!("unknown built-in tool `{name}`"))),
389        }
390    }
391
392    fn get_display_argument(
393        &self,
394        name: &str,
395        arguments: &serde_json::Value,
396    ) -> Result<String, ToolError> {
397        match name {
398            "read" => self.read.get_display_argument(name, arguments),
399            "write" => self.write.get_display_argument(name, arguments),
400            "edit" => self.edit.get_display_argument(name, arguments),
401            "bash" => self.bash.get_display_argument(name, arguments),
402            "webfetch" => webfetch::WebfetchTool.get_display_argument(name, arguments),
403            _ => Err(tool_error(format!("unknown built-in tool `{name}`"))),
404        }
405    }
406
407    async fn prepare(
408        &self,
409        ctx: ToolPreparationContext,
410        call: ToolCall,
411    ) -> Result<PreparedTool, ToolError> {
412        match call.name.as_str() {
413            "read" => self.read.prepare(ctx, call).await,
414            "write" => self.write.prepare(ctx, call).await,
415            "edit" => self.edit.prepare(ctx, call).await,
416            "bash" => self.bash.prepare(ctx, call).await,
417            "webfetch" => webfetch::WebfetchTool.prepare(ctx, call).await,
418            _ => Err(tool_error(format!("unknown built-in tool `{}`", call.name))),
419        }
420    }
421
422    async fn prepare_parallel(
423        &self,
424        ctx: ToolPreparationContext,
425        calls: Vec<ToolCall>,
426    ) -> Vec<Result<PreparedTool, ToolError>> {
427        // Route each same-tool group to the inner provider's batch
428        // preparation so same-target edits and writes chain; every other
429        // tool keeps per-call preparation.
430        let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
431        for (index, call) in calls.iter().enumerate() {
432            if let Some((_, indexes)) = groups.iter_mut().find(|(name, _)| *name == call.name) {
433                indexes.push(index);
434            } else {
435                groups.push((call.name.clone(), vec![index]));
436            }
437        }
438        let mut results: Vec<Option<Result<PreparedTool, ToolError>>> =
439            (0..calls.len()).map(|_| None).collect();
440        for (name, indexes) in groups {
441            let group_calls = indexes
442                .iter()
443                .map(|index| calls[*index].clone())
444                .collect::<Vec<_>>();
445            let prepared = match name.as_str() {
446                "read" => self.read.prepare_parallel(ctx.clone(), group_calls).await,
447                "write" => self.write.prepare_parallel(ctx.clone(), group_calls).await,
448                "edit" => self.edit.prepare_parallel(ctx.clone(), group_calls).await,
449                "bash" => self.bash.prepare_parallel(ctx.clone(), group_calls).await,
450                _ => {
451                    let mut prepared = Vec::with_capacity(group_calls.len());
452                    for call in group_calls {
453                        prepared.push(self.prepare(ctx.clone(), call).await);
454                    }
455                    prepared
456                }
457            };
458            for (index, result) in indexes.into_iter().zip(prepared) {
459                results[index] = Some(result);
460            }
461        }
462        results
463            .into_iter()
464            .map(|result| result.expect("every built-in call is prepared"))
465            .collect()
466    }
467}
468
469#[cfg(test)]
470mod tests;