1use 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 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 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;