1use crate::{
8 agent_resource::{Capability, ResourceDirectory, ResourceScope},
9 config::{Config, ResolvedConfig},
10 ctx_dirs::{self, ConfigSourceKind},
11 traits::{SearchOptions, Tool, ToolRegistry},
12};
13use anyhow::{bail, ensure, Context, Result};
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use sha2::{Digest, Sha256};
18use std::{
19 collections::{BTreeMap, HashSet},
20 fs,
21 path::{Component, Path, PathBuf},
22 sync::Arc,
23};
24
25const RESOURCE_SCHEMA_VERSION: u32 = 1;
26const MAX_OUTPUT_BYTES: u64 = 1024 * 1024;
27pub const CATALOG_CONTRACT_VERSION: u32 = 1;
28
29#[derive(Debug, Clone, Deserialize, Serialize)]
31#[serde(deny_unknown_fields)]
32pub struct ToolBindingResource {
33 pub schema_version: u32,
34 pub tool: ToolBindingSettings,
35 #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
36 pub config: toml::Table,
37 #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
38 pub fixed: toml::Table,
39 #[serde(default, skip_serializing_if = "BindingRestrictions::is_empty")]
40 pub restrictions: BindingRestrictions,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct ToolBindingSettings {
46 pub name: String,
47 pub implementation: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub description: Option<String>,
50 #[serde(default, rename = "override")]
51 pub override_existing: bool,
52}
53
54#[derive(Debug, Default, Clone, Deserialize, Serialize)]
55#[serde(default, deny_unknown_fields)]
56pub struct BindingRestrictions {
57 #[serde(skip_serializing_if = "Vec::is_empty")]
58 pub paths: Vec<String>,
59 #[serde(skip_serializing_if = "Vec::is_empty")]
60 pub sources: Vec<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub max_output_bytes: Option<u64>,
63}
64
65impl BindingRestrictions {
66 fn is_empty(&self) -> bool {
67 self.paths.is_empty() && self.sources.is_empty() && self.max_output_bytes.is_none()
68 }
69
70 fn validate(&self) -> Result<()> {
71 let mut normalized_paths = HashSet::new();
72 for path in &self.paths {
73 ensure!(
74 normalized_paths.insert(normalize_resource_path(path)?),
75 "duplicate restricted path"
76 );
77 }
78 ensure!(
79 self.sources.iter().all(|source| {
80 !source.trim().is_empty() && source.len() <= 1024 && !source.contains('\0')
81 }),
82 "invalid restricted source"
83 );
84 ensure!(
85 self.sources.iter().collect::<HashSet<_>>().len() == self.sources.len(),
86 "duplicate restricted source"
87 );
88 if let Some(limit) = self.max_output_bytes {
89 ensure!(
90 (1..=MAX_OUTPUT_BYTES).contains(&limit),
91 "max_output_bytes must be between 1 and {MAX_OUTPUT_BYTES}"
92 );
93 }
94 Ok(())
95 }
96}
97
98impl ToolBindingResource {
99 pub fn parse(content: &str) -> Result<Self> {
100 let resource: Self = toml::from_str(content).context("invalid tool resource TOML")?;
101 resource.validate()?;
102 Ok(resource)
103 }
104
105 pub fn validate(&self) -> Result<()> {
106 ensure!(
107 self.schema_version == RESOURCE_SCHEMA_VERSION,
108 "unsupported tool resource schema version"
109 );
110 validate_identifier(&self.tool.name, "tool name")?;
111 validate_identifier(&self.tool.implementation, "tool implementation")?;
112 ensure!(
113 !reserved_public_name(&self.tool.name),
114 "reserved public tool name"
115 );
116 if let Some(description) = &self.tool.description {
117 ensure!(
118 !description.trim().is_empty() && description.len() <= 1024,
119 "tool description must contain 1-1024 bytes"
120 );
121 }
122 self.restrictions.validate()
123 }
124
125 pub fn resource_version(&self) -> Result<String> {
128 Ok(format!(
129 "sha256:{:x}",
130 Sha256::digest(serde_json::to_vec(self)?)
131 ))
132 }
133}
134
135pub fn resource_directories(
137 resolved: &ResolvedConfig,
138 root: &Path,
139 global: &Path,
140) -> Vec<ResourceDirectory> {
141 if matches!(
142 resolved.source,
143 ConfigSourceKind::Explicit | ConfigSourceKind::Env
144 ) {
145 return resolved
146 .path
147 .as_ref()
148 .map(|path| {
149 let path = if path.is_absolute() {
150 path.clone()
151 } else {
152 root.join(path)
153 };
154 ResourceDirectory {
155 path: path.parent().unwrap_or(root).join("tools"),
156 scope: ResourceScope::Explicit,
157 }
158 })
159 .into_iter()
160 .collect();
161 }
162 vec![
163 ResourceDirectory {
164 path: global.join("tools"),
165 scope: ResourceScope::Global,
166 },
167 ResourceDirectory {
168 path: root.join(".ctx/tools"),
169 scope: ResourceScope::Workspace,
170 },
171 ]
172}
173
174pub fn cli_resource_directories(resolved: &ResolvedConfig) -> Result<Vec<ResourceDirectory>> {
175 Ok(resource_directories(
176 resolved,
177 &std::env::current_dir()?,
178 &ctx_dirs::config_dir(),
179 ))
180}
181
182#[derive(Debug, Clone, Serialize)]
183pub struct LoadedToolResource {
184 pub path: PathBuf,
185 pub scope: ResourceScope,
186 pub resource_version: String,
187 pub definition: ToolBindingResource,
188}
189
190pub fn load_resources(
192 directories: &[ResourceDirectory],
193 config: &Config,
194) -> Result<BTreeMap<String, LoadedToolResource>> {
195 load_resources_selected(directories, config, None)
196}
197
198pub fn load_named_resource(
199 directories: &[ResourceDirectory],
200 config: &Config,
201 name: &str,
202) -> Result<BTreeMap<String, LoadedToolResource>> {
203 validate_identifier(name, "tool name")?;
204 load_resources_selected(directories, config, Some(name))
205}
206
207fn load_resources_selected(
208 directories: &[ResourceDirectory],
209 config: &Config,
210 selected: Option<&str>,
211) -> Result<BTreeMap<String, LoadedToolResource>> {
212 let mut resources: BTreeMap<String, LoadedToolResource> = BTreeMap::new();
213 let mut visited = HashSet::new();
214 for directory in directories {
215 let canonical = match directory.path.canonicalize() {
216 Ok(path) => path,
217 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
218 Err(error) => {
219 return Err(error).with_context(|| format!("reading {}", directory.path.display()))
220 }
221 };
222 if !visited.insert(canonical.clone()) {
223 continue;
224 }
225 let mut paths = fs::read_dir(&canonical)
226 .with_context(|| format!("reading {}", canonical.display()))?
227 .map(|entry| entry.map(|entry| entry.path()))
228 .collect::<std::io::Result<Vec<_>>>()?;
229 paths.sort();
230 let mut names = HashSet::new();
231 for path in paths.into_iter().filter(|path| {
232 path.extension()
233 .is_some_and(|extension| extension == "toml")
234 }) {
235 let content =
236 fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
237 if let Some(selected) = selected {
238 let declared_name =
239 toml::from_str::<toml::Value>(&content)
240 .ok()
241 .and_then(|value| {
242 value
243 .get("tool")
244 .and_then(|tool| tool.get("name"))
245 .and_then(toml::Value::as_str)
246 .map(str::to_owned)
247 });
248 if declared_name.as_deref() != Some(selected) {
249 continue;
250 }
251 }
252 let definition = ToolBindingResource::parse(&content)
253 .with_context(|| format!("tool resource {}", path.display()))?;
254 let name = &definition.tool.name;
255 ensure!(
256 names.insert(name.clone()),
257 "duplicate tool binding '{name}' in {}",
258 canonical.display()
259 );
260 ensure!(
261 !config.tools.script.contains_key(name),
262 "tool resource {} conflicts with legacy Lua tool '{name}'; rename or migrate it",
263 path.display()
264 );
265 if let Some(previous) = resources.get(name) {
266 ensure!(
267 definition.tool.override_existing,
268 "tool binding '{name}' in {} shadows {}; set tool.override = true to replace it",
269 path.display(),
270 previous.path.display()
271 );
272 }
273 let resource_version = definition.resource_version()?;
274 resources.insert(
275 name.clone(),
276 LoadedToolResource {
277 path,
278 scope: directory.scope,
279 resource_version,
280 definition,
281 },
282 );
283 }
284 }
285 Ok(resources)
286}
287
288fn validate_identifier(value: &str, label: &str) -> Result<()> {
289 let mut chars = value.chars();
290 ensure!(
291 value.len() <= 128
292 && chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
293 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')),
294 "invalid {label}"
295 );
296 Ok(())
297}
298
299fn reserved_public_name(name: &str) -> bool {
300 matches!(name, "search" | "get" | "process.exec" | "agent.invoke")
301 || ["workspace.", "git.", "runtime.", "mcp."]
302 .iter()
303 .any(|prefix| name.starts_with(prefix))
304}
305
306fn normalize_resource_path(value: &str) -> Result<PathBuf> {
307 ensure!(
308 !value.is_empty() && !value.contains('\0'),
309 "invalid restricted path"
310 );
311 let path = Path::new(value);
312 ensure!(!path.is_absolute(), "restricted paths must be relative");
313 let mut normalized = PathBuf::new();
314 for component in path.components() {
315 match component {
316 Component::Normal(part) => normalized.push(part),
317 Component::CurDir => {}
318 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
319 anyhow::bail!("restricted paths cannot escape the workspace")
320 }
321 }
322 }
323 if normalized.as_os_str().is_empty() {
324 ensure!(
325 path.components()
326 .all(|component| matches!(component, Component::CurDir)),
327 "invalid restricted path"
328 );
329 return Ok(PathBuf::from("."));
330 }
331 Ok(normalized)
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
335#[serde(rename_all = "snake_case")]
336pub enum RestrictionKind {
337 Paths,
338 Sources,
339 MaxOutputBytes,
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
343#[serde(rename_all = "snake_case")]
344pub enum ToolTrustClass {
345 Builtin,
346 Compiled,
347 LuaPrivileged,
348 McpExternal,
349}
350
351#[derive(Debug, Clone, Serialize)]
353pub struct ToolImplementationDescriptor {
354 pub id: String,
355 pub version: String,
356 pub implementation_description: String,
357 pub default_public_description: String,
358 pub config_schema: Value,
359 pub input_schema: Value,
360 pub capabilities: Vec<Capability>,
361 pub supported_restrictions: Vec<RestrictionKind>,
362 pub trust_class: ToolTrustClass,
363}
364
365#[derive(Debug, Clone, Serialize)]
366pub struct ResolvedToolBinding {
367 pub name: String,
368 pub description: String,
369 pub implementation_description: String,
370 pub implementation_id: String,
371 pub implementation_version: String,
372 pub trust_class: ToolTrustClass,
373 pub capabilities: Vec<Capability>,
374 pub public_schema: Value,
375 pub config: Value,
376 pub fixed: Value,
377 pub restrictions: BindingRestrictions,
378 pub binding_version: String,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub remote_metadata: Option<String>,
381}
382
383#[derive(Debug, Clone, Serialize)]
384pub struct ResolvedToolResource {
385 pub path: PathBuf,
386 pub scope: ResourceScope,
387 pub resource_version: String,
388 pub binding: ResolvedToolBinding,
389}
390
391impl ToolImplementationDescriptor {
392 pub fn validate(&self) -> Result<()> {
393 validate_identifier(&self.id, "implementation id")?;
394 ensure!(
395 self.id.starts_with("builtin.")
396 || self.id.starts_with("rust.")
397 || self.id.starts_with("lua.")
398 || self.id.starts_with("mcp."),
399 "implementation id uses an unowned namespace"
400 );
401 ensure!(
402 !self.version.trim().is_empty() && self.version.len() <= 256,
403 "implementation version must contain 1-256 bytes"
404 );
405 for description in [
406 &self.implementation_description,
407 &self.default_public_description,
408 ] {
409 ensure!(
410 !description.trim().is_empty() && description.len() <= 1024,
411 "implementation descriptions must contain 1-1024 bytes"
412 );
413 }
414 validate_object_schema(&self.config_schema, "configuration")?;
415 validate_object_schema(&self.input_schema, "input")?;
416 ensure!(
417 self.capabilities.iter().collect::<HashSet<_>>().len() == self.capabilities.len(),
418 "duplicate implementation capability"
419 );
420 ensure!(
421 self.supported_restrictions
422 .iter()
423 .collect::<HashSet<_>>()
424 .len()
425 == self.supported_restrictions.len(),
426 "duplicate supported restriction"
427 );
428 Ok(())
429 }
430}
431
432fn validate_object_schema(schema: &Value, label: &str) -> Result<()> {
433 ensure!(schema.is_object(), "{label} schema must be an object");
434 ensure!(
435 schema.get("type").and_then(Value::as_str) == Some("object"),
436 "{label} schema must declare object type"
437 );
438 Ok(())
439}
440
441#[derive(Debug, Clone)]
443pub struct HostToolAuthority {
444 workspace_root: PathBuf,
445 capabilities: Vec<Capability>,
446 paths: Vec<PathBuf>,
447 sources: Vec<String>,
448}
449
450impl HostToolAuthority {
451 pub fn new(workspace_root: &Path, capabilities: Vec<Capability>) -> Result<Self> {
452 let workspace_root = workspace_root
453 .canonicalize()
454 .context("canonicalizing tool-authority workspace root")?;
455 ensure!(
456 capabilities.iter().collect::<HashSet<_>>().len() == capabilities.len(),
457 "duplicate host capability"
458 );
459 Ok(Self {
460 workspace_root,
461 capabilities,
462 paths: Vec::new(),
463 sources: Vec::new(),
464 })
465 }
466
467 pub fn enroll_path(&mut self, path: &Path) -> Result<()> {
468 let path = if path.is_absolute() {
469 path.to_path_buf()
470 } else {
471 self.workspace_root.join(path)
472 }
473 .canonicalize()
474 .context("canonicalizing enrolled tool path")?;
475 ensure!(
476 path.starts_with(&self.workspace_root),
477 "enrolled tool path escapes the workspace"
478 );
479 ensure!(!self.paths.contains(&path), "duplicate enrolled tool path");
480 self.paths.push(path);
481 Ok(())
482 }
483
484 pub fn enroll_source(&mut self, source: String) -> Result<()> {
485 ensure!(
486 !source.trim().is_empty() && source.len() <= 1024 && !source.contains('\0'),
487 "invalid enrolled source"
488 );
489 ensure!(!self.sources.contains(&source), "duplicate enrolled source");
490 self.sources.push(source);
491 Ok(())
492 }
493
494 pub fn workspace_root(&self) -> &Path {
495 &self.workspace_root
496 }
497
498 pub fn capabilities(&self) -> &[Capability] {
499 &self.capabilities
500 }
501
502 pub fn paths(&self) -> &[PathBuf] {
503 &self.paths
504 }
505
506 pub fn sources(&self) -> &[String] {
507 &self.sources
508 }
509}
510
511pub struct ToolBindingRequest {
513 pub resource: ToolBindingResource,
514 pub resolved: ResolvedToolBinding,
515 pub authority: Arc<HostToolAuthority>,
516}
517
518#[async_trait]
521pub trait ToolImplementationFactory: Send + Sync {
522 fn descriptor(&self) -> &ToolImplementationDescriptor;
523 fn validate_binding(&self, _resource: &ToolBindingResource) -> Result<()> {
525 Ok(())
526 }
527 async fn bind(&self, request: ToolBindingRequest) -> Result<Box<dyn Tool>>;
528}
529
530#[derive(Clone, Default)]
531pub struct ToolImplementationCatalog {
532 factories: BTreeMap<String, Arc<dyn ToolImplementationFactory>>,
533}
534
535impl ToolImplementationCatalog {
536 pub fn new() -> Self {
537 Self::default()
538 }
539
540 pub fn register(&mut self, factory: Arc<dyn ToolImplementationFactory>) -> Result<()> {
541 let descriptor = factory.descriptor();
542 descriptor.validate()?;
543 ensure!(
544 !self.factories.contains_key(&descriptor.id),
545 "duplicate tool implementation '{}'",
546 descriptor.id
547 );
548 self.factories.insert(descriptor.id.clone(), factory);
549 Ok(())
550 }
551
552 pub fn find(&self, id: &str) -> Option<&Arc<dyn ToolImplementationFactory>> {
553 self.factories.get(id)
554 }
555
556 pub fn descriptors(&self) -> impl Iterator<Item = &ToolImplementationDescriptor> {
557 self.factories.values().map(|factory| factory.descriptor())
558 }
559
560 pub fn resolve(&self, resource: &ToolBindingResource) -> Result<ResolvedToolBinding> {
561 let factory = self.find(&resource.tool.implementation).with_context(|| {
562 format!(
563 "unknown tool implementation '{}'",
564 resource.tool.implementation
565 )
566 })?;
567 let descriptor = factory.descriptor();
568 for restriction in resource.restrictions.kinds() {
569 ensure!(
570 descriptor.supported_restrictions.contains(&restriction),
571 "implementation '{}' cannot enforce restriction '{restriction:?}'",
572 descriptor.id
573 );
574 }
575 let config = toml_table_to_json(&resource.config)?;
576 let fixed = toml_table_to_json(&resource.fixed)?;
577 validate_schema_value(&descriptor.config_schema, &config, "config")?;
578 validate_fixed(&descriptor.input_schema, &fixed)?;
579 factory.validate_binding(resource)?;
580 let public_schema = derive_public_schema(&descriptor.input_schema, &resource.fixed)?;
581 let description = resource
582 .tool
583 .description
584 .clone()
585 .unwrap_or_else(|| descriptor.default_public_description.clone());
586 let identity = serde_json::json!({
587 "resource": resource,
588 "implementation_id": descriptor.id,
589 "implementation_version": descriptor.version,
590 "input_schema": descriptor.input_schema,
591 "public_schema": public_schema,
592 "config": config,
593 "fixed": fixed,
594 "restrictions": resource.restrictions,
595 });
596 let binding_version = format!(
597 "sha256:{:x}",
598 Sha256::digest(serde_json::to_vec(&identity)?)
599 );
600 Ok(ResolvedToolBinding {
601 name: resource.tool.name.clone(),
602 description,
603 implementation_description: descriptor.implementation_description.clone(),
604 implementation_id: descriptor.id.clone(),
605 implementation_version: descriptor.version.clone(),
606 trust_class: descriptor.trust_class,
607 capabilities: descriptor.capabilities.clone(),
608 public_schema,
609 config,
610 fixed,
611 restrictions: resource.restrictions.clone(),
612 binding_version,
613 remote_metadata: None,
614 })
615 }
616}
617
618pub fn resolve_resources(
619 loaded: BTreeMap<String, LoadedToolResource>,
620 catalog: &ToolImplementationCatalog,
621) -> Result<BTreeMap<String, ResolvedToolResource>> {
622 loaded
623 .into_iter()
624 .map(|(name, loaded)| {
625 let binding = if catalog
626 .find(&loaded.definition.tool.implementation)
627 .is_some()
628 {
629 catalog.resolve(&loaded.definition)
630 } else if mcp_reference(&loaded.definition.tool.implementation).is_some() {
631 resolve_unprepared_mcp_binding(&loaded.definition)
632 } else {
633 catalog.resolve(&loaded.definition)
634 }
635 .with_context(|| format!("resolving tool resource {}", loaded.path.display()))?;
636 Ok((
637 name,
638 ResolvedToolResource {
639 path: loaded.path,
640 scope: loaded.scope,
641 resource_version: loaded.resource_version,
642 binding,
643 },
644 ))
645 })
646 .collect()
647}
648
649pub fn mcp_reference(implementation: &str) -> Option<(&str, &str)> {
650 let rest = implementation.strip_prefix("mcp.")?;
651 let (server, remote) = rest.split_once('.')?;
652 (!server.is_empty() && !remote.is_empty()).then_some((server, remote))
653}
654
655fn validate_mcp_declaration(resource: &ToolBindingResource) -> Result<()> {
656 ensure!(
657 resource.config.is_empty(),
658 "MCP aliases do not accept configuration"
659 );
660 ensure!(
661 resource.restrictions.paths.is_empty() && resource.restrictions.sources.is_empty(),
662 "MCP aliases cannot enforce path or source restrictions"
663 );
664 ensure!(
665 mcp_reference(&resource.tool.implementation).is_some(),
666 "invalid MCP implementation reference"
667 );
668 Ok(())
669}
670
671fn resolve_unprepared_mcp_binding(resource: &ToolBindingResource) -> Result<ResolvedToolBinding> {
672 validate_mcp_declaration(resource)?;
673 let fixed = toml_table_to_json(&resource.fixed)?;
674 Ok(ResolvedToolBinding {
675 name: resource.tool.name.clone(),
676 description: resource
677 .tool
678 .description
679 .clone()
680 .unwrap_or_else(|| "Alias for an MCP tool".into()),
681 implementation_description: "Remote MCP tool; schema requires authorized discovery".into(),
682 implementation_id: resource.tool.implementation.clone(),
683 implementation_version: "unresolved".into(),
684 trust_class: ToolTrustClass::McpExternal,
685 capabilities: vec![Capability::ProcessExecute, Capability::ExternalSideEffect],
686 public_schema: serde_json::json!({"type":"object", "remote_metadata":"unresolved"}),
687 config: serde_json::json!({}),
688 fixed,
689 restrictions: resource.restrictions.clone(),
690 binding_version: resource.resource_version()?,
691 remote_metadata: Some("unresolved".into()),
692 })
693}
694
695pub fn resolve_mcp_binding(
696 resource: &ToolBindingResource,
697 remote_schema: &Value,
698 remote_description: &str,
699 server_identity: &Value,
700) -> Result<ResolvedToolBinding> {
701 validate_mcp_declaration(resource)?;
702 validate_object_schema(remote_schema, "MCP input")?;
703 let fixed = toml_table_to_json(&resource.fixed)?;
704 validate_fixed(remote_schema, &fixed)?;
705 let public_schema = derive_public_schema(remote_schema, &resource.fixed)?;
706 let implementation_version = format!(
707 "sha256:{:x}",
708 Sha256::digest(serde_json::to_vec(&serde_json::json!({
709 "server": server_identity, "schema": remote_schema,
710 }))?)
711 );
712 let identity = serde_json::json!({
713 "resource": resource,
714 "implementation_version": implementation_version,
715 "remote_schema": remote_schema,
716 "public_schema": public_schema,
717 });
718 Ok(ResolvedToolBinding {
719 name: resource.tool.name.clone(),
720 description: resource
721 .tool
722 .description
723 .clone()
724 .unwrap_or_else(|| remote_description.to_string()),
725 implementation_description: "Authorized remote MCP tool".into(),
726 implementation_id: resource.tool.implementation.clone(),
727 implementation_version,
728 trust_class: ToolTrustClass::McpExternal,
729 capabilities: vec![Capability::ProcessExecute, Capability::ExternalSideEffect],
730 public_schema,
731 config: serde_json::json!({}),
732 fixed,
733 restrictions: resource.restrictions.clone(),
734 binding_version: format!(
735 "sha256:{:x}",
736 Sha256::digest(serde_json::to_vec(&identity)?)
737 ),
738 remote_metadata: Some("resolved".into()),
739 })
740}
741
742struct CoreFactory {
743 descriptor: ToolImplementationDescriptor,
744}
745
746#[async_trait]
747impl ToolImplementationFactory for CoreFactory {
748 fn descriptor(&self) -> &ToolImplementationDescriptor {
749 &self.descriptor
750 }
751
752 fn validate_binding(&self, resource: &ToolBindingResource) -> Result<()> {
753 match self.descriptor.id.as_str() {
754 "builtin.scoped_file_read" => {
755 let root = resource
756 .config
757 .get("root")
758 .and_then(toml::Value::as_str)
759 .context("config.root must be a string")?;
760 let root = normalize_resource_path(root)?;
761 ensure!(
762 resource
763 .restrictions
764 .paths
765 .iter()
766 .map(|path| normalize_resource_path(path))
767 .collect::<Result<Vec<_>>>()?
768 .contains(&root),
769 "config.root must be included in restrictions.paths"
770 );
771 }
772 "builtin.retrieval.search" | "builtin.retrieval.get" => {
773 let source = resource
774 .config
775 .get("source")
776 .and_then(toml::Value::as_str)
777 .context("config.source must be a string")?;
778 ensure!(
779 resource
780 .restrictions
781 .sources
782 .iter()
783 .any(|allowed| allowed == source),
784 "config.source must be included in restrictions.sources"
785 );
786 }
787 _ => {}
788 }
789 Ok(())
790 }
791
792 async fn bind(&self, request: ToolBindingRequest) -> Result<Box<dyn Tool>> {
793 match self.descriptor.id.as_str() {
794 "builtin.scoped_file_read" => Ok(Box::new(ScopedFileRead::bind(
795 request,
796 self.descriptor.input_schema.clone(),
797 )?)),
798 "builtin.retrieval.search" => Ok(Box::new(ScopedRetrieval::bind(
799 request,
800 self.descriptor.input_schema.clone(),
801 RetrievalOperation::Search,
802 )?)),
803 "builtin.retrieval.get" => Ok(Box::new(ScopedRetrieval::bind(
804 request,
805 self.descriptor.input_schema.clone(),
806 RetrievalOperation::Get,
807 )?)),
808 _ => bail!(
809 "tool implementation '{}' is not connected to runtime dispatch yet",
810 self.descriptor.id
811 ),
812 }
813 }
814}
815
816#[derive(Clone, Copy)]
817enum RetrievalOperation {
818 Search,
819 Get,
820}
821
822struct ScopedRetrieval {
823 binding: ResolvedToolBinding,
824 input_schema: Value,
825 fixed: serde_json::Map<String, Value>,
826 source: String,
827 operation: RetrievalOperation,
828}
829
830impl ScopedRetrieval {
831 fn bind(
832 request: ToolBindingRequest,
833 input_schema: Value,
834 operation: RetrievalOperation,
835 ) -> Result<Self> {
836 let source = request
837 .resource
838 .config
839 .get("source")
840 .and_then(toml::Value::as_str)
841 .context("config.source must be a string")?
842 .to_string();
843 ensure!(
844 request
845 .resolved
846 .restrictions
847 .sources
848 .iter()
849 .any(|allowed| allowed == &source),
850 "retrieval source is not allowed by the binding"
851 );
852 ensure!(
853 request.authority.sources().contains(&source),
854 "retrieval source is not granted by the host"
855 );
856 let fixed = request
857 .resolved
858 .fixed
859 .as_object()
860 .context("fixed arguments must be an object")?
861 .clone();
862 Ok(Self {
863 binding: request.resolved,
864 input_schema,
865 fixed,
866 source,
867 operation,
868 })
869 }
870
871 fn effective_arguments(&self, arguments: Value) -> Result<serde_json::Map<String, Value>> {
872 validate_schema_value(&self.binding.public_schema, &arguments, "arguments")?;
873 let mut arguments = arguments
874 .as_object()
875 .context("tool arguments must be an object")?
876 .clone();
877 for (name, value) in &self.fixed {
878 ensure!(
879 !arguments.contains_key(name),
880 "fixed argument '{name}' cannot be overridden"
881 );
882 arguments.insert(name.clone(), value.clone());
883 }
884 validate_schema_value(
885 &self.input_schema,
886 &Value::Object(arguments.clone()),
887 "arguments",
888 )?;
889 Ok(arguments)
890 }
891
892 fn check_output_bound(&self, output: &Value) -> Result<()> {
893 ensure!(
894 serde_json::to_vec(output)?.len() as u64
895 <= self
896 .binding
897 .restrictions
898 .max_output_bytes
899 .unwrap_or(MAX_OUTPUT_BYTES),
900 "serialized tool result exceeds output limit"
901 );
902 Ok(())
903 }
904}
905
906#[async_trait]
907impl Tool for ScopedRetrieval {
908 fn capabilities(&self) -> Option<Vec<Capability>> {
909 Some(self.binding.capabilities.clone())
910 }
911
912 fn binding_metadata(&self) -> Option<Value> {
913 Some(binding_metadata(&self.binding))
914 }
915
916 fn name(&self) -> &str {
917 &self.binding.name
918 }
919
920 fn description(&self) -> &str {
921 &self.binding.description
922 }
923
924 fn parameters_schema(&self) -> Value {
925 self.binding.public_schema.clone()
926 }
927
928 fn validate_arguments(&self, arguments: &Value) -> Result<()> {
929 validate_schema_value(&self.binding.public_schema, arguments, "arguments")
930 }
931
932 fn approval_arguments(&self, arguments: &Value) -> Result<Value> {
933 Ok(Value::Object(self.effective_arguments(arguments.clone())?))
934 }
935
936 async fn execute(
937 &self,
938 arguments: Value,
939 context: &crate::traits::ToolContext,
940 ) -> Result<Value> {
941 let arguments = self.effective_arguments(arguments)?;
942 let output = match self.operation {
943 RetrievalOperation::Search => {
944 let query = arguments
945 .get("query")
946 .and_then(Value::as_str)
947 .context("query must be a string")?;
948 let limit = arguments.get("limit").and_then(Value::as_i64);
949 let results = context
950 .search(
951 query,
952 SearchOptions {
953 mode: Some("keyword".into()),
954 limit,
955 source: Some(self.source.clone()),
956 },
957 )
958 .await?;
959 ensure!(
960 results.iter().all(|result| result.source == self.source),
961 "retrieval returned a result outside the bound source"
962 );
963 serde_json::json!({"results": results})
964 }
965 RetrievalOperation::Get => {
966 let id = arguments
967 .get("id")
968 .and_then(Value::as_str)
969 .context("id must be a string")?;
970 ensure!(!id.contains(':'), "invalid document id");
971 let document = context.get(id).await?;
972 ensure!(
973 document.source == self.source,
974 "document is outside the bound source"
975 );
976 serde_json::to_value(document)?
977 }
978 };
979 self.check_output_bound(&output)?;
980 Ok(output)
981 }
982}
983
984pub fn core_catalog() -> Result<ToolImplementationCatalog> {
985 let mut catalog = ToolImplementationCatalog::new();
986 for descriptor in [
987 ToolImplementationDescriptor {
988 id: "builtin.scoped_file_read".into(),
989 version: format!("{}:1", env!("CARGO_PKG_VERSION")),
990 implementation_description:
991 "Read bounded UTF-8 files through a host-scoped path accessor".into(),
992 default_public_description: "Read a scoped UTF-8 file".into(),
993 config_schema: serde_json::json!({
994 "type": "object",
995 "properties": {"root": {"type": "string", "minLength": 1}},
996 "required": ["root"],
997 "additionalProperties": false
998 }),
999 input_schema: serde_json::json!({
1000 "type": "object",
1001 "properties": {
1002 "path": {"type": "string", "minLength": 1},
1003 "max_bytes": {"type": "integer", "minimum": 1, "maximum": 1048576}
1004 },
1005 "required": ["path"],
1006 "additionalProperties": false
1007 }),
1008 capabilities: vec![Capability::ReadOnly],
1009 supported_restrictions: vec![RestrictionKind::Paths, RestrictionKind::MaxOutputBytes],
1010 trust_class: ToolTrustClass::Builtin,
1011 },
1012 ToolImplementationDescriptor {
1013 id: "builtin.retrieval.search".into(),
1014 version: format!("{}:1", env!("CARGO_PKG_VERSION")),
1015 implementation_description:
1016 "Search indexed context through a host-scoped source accessor".into(),
1017 default_public_description: "Search scoped indexed context".into(),
1018 config_schema: serde_json::json!({
1019 "type": "object",
1020 "properties": {"source": {"type": "string", "minLength": 1, "maxLength": 1024}},
1021 "required": ["source"],
1022 "additionalProperties": false
1023 }),
1024 input_schema: serde_json::json!({
1025 "type": "object",
1026 "properties": {
1027 "query": {"type": "string", "minLength": 1, "maxLength": 8192},
1028 "limit": {"type": "integer", "minimum": 1, "maximum": 100}
1029 },
1030 "required": ["query"],
1031 "additionalProperties": false
1032 }),
1033 capabilities: vec![Capability::ReadOnly],
1034 supported_restrictions: vec![RestrictionKind::Sources, RestrictionKind::MaxOutputBytes],
1035 trust_class: ToolTrustClass::Builtin,
1036 },
1037 ToolImplementationDescriptor {
1038 id: "builtin.retrieval.get".into(),
1039 version: format!("{}:1", env!("CARGO_PKG_VERSION")),
1040 implementation_description:
1041 "Retrieve indexed context through a host-scoped source accessor".into(),
1042 default_public_description: "Get a scoped indexed document".into(),
1043 config_schema: serde_json::json!({
1044 "type": "object",
1045 "properties": {"source": {"type": "string", "minLength": 1, "maxLength": 1024}},
1046 "required": ["source"],
1047 "additionalProperties": false
1048 }),
1049 input_schema: serde_json::json!({
1050 "type": "object",
1051 "properties": {"id": {"type": "string", "minLength": 1, "maxLength": 128}},
1052 "required": ["id"],
1053 "additionalProperties": false
1054 }),
1055 capabilities: vec![Capability::ReadOnly],
1056 supported_restrictions: vec![RestrictionKind::Sources, RestrictionKind::MaxOutputBytes],
1057 trust_class: ToolTrustClass::Builtin,
1058 },
1059 ] {
1060 catalog.register(Arc::new(CoreFactory { descriptor }))?;
1061 }
1062 Ok(catalog)
1063}
1064
1065pub fn core_metadata_catalog() -> Result<ToolImplementationCatalog> {
1067 core_catalog()
1068}
1069
1070pub async fn bind_resources(
1071 loaded: &BTreeMap<String, LoadedToolResource>,
1072 catalog: &ToolImplementationCatalog,
1073 authority: Arc<HostToolAuthority>,
1074) -> Result<ToolRegistry> {
1075 let mut registry = ToolRegistry::new();
1076 for resource in loaded.values() {
1077 let resolved = catalog.resolve(&resource.definition)?;
1078 ensure!(
1079 resolved
1080 .capabilities
1081 .iter()
1082 .all(|capability| authority.capabilities().contains(capability)),
1083 "tool binding '{}' requires capability not granted by the host",
1084 resolved.name
1085 );
1086 let factory = catalog
1087 .find(&resolved.implementation_id)
1088 .context("resolved implementation disappeared from catalog")?;
1089 let tool = factory
1090 .bind(ToolBindingRequest {
1091 resource: resource.definition.clone(),
1092 resolved,
1093 authority: authority.clone(),
1094 })
1095 .await
1096 .with_context(|| format!("binding tool resource {}", resource.path.display()))?;
1097 ensure!(
1098 registry.find(tool.name()).is_none(),
1099 "duplicate bound tool '{}'",
1100 tool.name()
1101 );
1102 registry.register(tool);
1103 }
1104 Ok(registry)
1105}
1106
1107struct ScopedFileRead {
1108 binding: ResolvedToolBinding,
1109 input_schema: Value,
1110 fixed: serde_json::Map<String, Value>,
1111 root: PathBuf,
1112}
1113
1114impl ScopedFileRead {
1115 fn bind(request: ToolBindingRequest, input_schema: Value) -> Result<Self> {
1116 let root = request
1117 .resource
1118 .config
1119 .get("root")
1120 .and_then(toml::Value::as_str)
1121 .context("config.root must be a string")?;
1122 let root = request
1123 .authority
1124 .workspace_root()
1125 .join(normalize_resource_path(root)?)
1126 .canonicalize()
1127 .context("canonicalizing scoped file root")?;
1128 ensure!(root.is_dir(), "scoped file root must be a directory");
1129 ensure!(
1130 request
1131 .authority
1132 .paths()
1133 .iter()
1134 .any(|granted| root.starts_with(granted)),
1135 "scoped file root is not granted by the host"
1136 );
1137 let fixed = request
1138 .resolved
1139 .fixed
1140 .as_object()
1141 .context("fixed arguments must be an object")?
1142 .clone();
1143 Ok(Self {
1144 binding: request.resolved,
1145 input_schema,
1146 fixed,
1147 root,
1148 })
1149 }
1150
1151 fn effective_arguments(&self, arguments: Value) -> Result<serde_json::Map<String, Value>> {
1152 validate_schema_value(&self.binding.public_schema, &arguments, "arguments")?;
1153 let mut arguments = arguments
1154 .as_object()
1155 .context("tool arguments must be an object")?
1156 .clone();
1157 for (name, value) in &self.fixed {
1158 ensure!(
1159 !arguments.contains_key(name),
1160 "fixed argument '{name}' cannot be overridden"
1161 );
1162 arguments.insert(name.clone(), value.clone());
1163 }
1164 validate_schema_value(
1165 &self.input_schema,
1166 &Value::Object(arguments.clone()),
1167 "arguments",
1168 )?;
1169 Ok(arguments)
1170 }
1171}
1172
1173#[async_trait]
1174impl Tool for ScopedFileRead {
1175 fn capabilities(&self) -> Option<Vec<Capability>> {
1176 Some(self.binding.capabilities.clone())
1177 }
1178
1179 fn binding_metadata(&self) -> Option<Value> {
1180 Some(binding_metadata(&self.binding))
1181 }
1182
1183 fn name(&self) -> &str {
1184 &self.binding.name
1185 }
1186
1187 fn description(&self) -> &str {
1188 &self.binding.description
1189 }
1190
1191 fn parameters_schema(&self) -> Value {
1192 self.binding.public_schema.clone()
1193 }
1194
1195 fn validate_arguments(&self, arguments: &Value) -> Result<()> {
1196 validate_schema_value(&self.binding.public_schema, arguments, "arguments")
1197 }
1198
1199 fn approval_arguments(&self, arguments: &Value) -> Result<Value> {
1200 Ok(Value::Object(self.effective_arguments(arguments.clone())?))
1201 }
1202
1203 async fn execute(
1204 &self,
1205 arguments: Value,
1206 _context: &crate::traits::ToolContext,
1207 ) -> Result<Value> {
1208 let arguments = self.effective_arguments(arguments)?;
1209 let path = arguments
1210 .get("path")
1211 .and_then(Value::as_str)
1212 .context("path must be a string")?;
1213 let relative = normalize_resource_path(path)?;
1214 let path = self
1215 .root
1216 .join(&relative)
1217 .canonicalize()
1218 .context("canonicalizing scoped file")?;
1219 ensure!(
1220 path.starts_with(&self.root),
1221 "scoped file path escapes its root"
1222 );
1223 ensure!(path.is_file(), "scoped file path must be a regular file");
1224 let requested = arguments
1225 .get("max_bytes")
1226 .and_then(Value::as_u64)
1227 .unwrap_or(MAX_OUTPUT_BYTES);
1228 let limit = requested.min(
1229 self.binding
1230 .restrictions
1231 .max_output_bytes
1232 .unwrap_or(MAX_OUTPUT_BYTES),
1233 );
1234 let metadata = tokio::fs::metadata(&path).await?;
1235 ensure!(metadata.len() <= limit, "scoped file exceeds output limit");
1236 let bytes = tokio::fs::read(&path).await?;
1237 ensure!(
1238 bytes.len() as u64 <= limit,
1239 "scoped file exceeds output limit"
1240 );
1241 let content = String::from_utf8(bytes).context("scoped file is not UTF-8")?;
1242 let output = serde_json::json!({"path": relative, "content": content});
1243 ensure!(
1244 serde_json::to_vec(&output)?.len() as u64
1245 <= self
1246 .binding
1247 .restrictions
1248 .max_output_bytes
1249 .unwrap_or(MAX_OUTPUT_BYTES),
1250 "serialized tool result exceeds output limit"
1251 );
1252 Ok(output)
1253 }
1254}
1255
1256pub(crate) fn binding_metadata(binding: &ResolvedToolBinding) -> Value {
1257 serde_json::json!({
1258 "binding_version": binding.binding_version,
1259 "implementation_id": binding.implementation_id,
1260 "implementation_version": binding.implementation_version,
1261 "public_schema": binding.public_schema,
1262 "fixed": sanitize_metadata(binding.fixed.clone()),
1263 "restrictions": binding.restrictions,
1264 "capabilities": binding.capabilities,
1265 "trust_class": binding.trust_class,
1266 })
1267}
1268
1269pub fn inspection_value(resource: &ResolvedToolResource) -> Result<Value> {
1270 let mut value = serde_json::to_value(resource)?;
1271 value["binding"]["config"] = sanitize_metadata(resource.binding.config.clone());
1272 value["binding"]["fixed"] = sanitize_metadata(resource.binding.fixed.clone());
1273 Ok(value)
1274}
1275
1276pub fn inspection_values(resources: &BTreeMap<String, ResolvedToolResource>) -> Result<Value> {
1277 Ok(Value::Object(
1278 resources
1279 .iter()
1280 .map(|(name, resource)| Ok((name.clone(), inspection_value(resource)?)))
1281 .collect::<Result<serde_json::Map<_, _>>>()?,
1282 ))
1283}
1284
1285pub(crate) fn compatibility_binding_metadata(
1286 name: &str,
1287 implementation_id: &str,
1288 public_schema: Value,
1289 capabilities: Vec<Capability>,
1290 trust_class: ToolTrustClass,
1291) -> Value {
1292 let implementation_version = format!(
1293 "{}:compatibility:{}",
1294 env!("CARGO_PKG_VERSION"),
1295 CATALOG_CONTRACT_VERSION
1296 );
1297 compatibility_binding_metadata_versioned(
1298 name,
1299 implementation_id,
1300 &implementation_version,
1301 public_schema,
1302 capabilities,
1303 trust_class,
1304 )
1305}
1306
1307pub(crate) fn compatibility_binding_metadata_versioned(
1308 name: &str,
1309 implementation_id: &str,
1310 implementation_version: &str,
1311 public_schema: Value,
1312 capabilities: Vec<Capability>,
1313 trust_class: ToolTrustClass,
1314) -> Value {
1315 let identity = serde_json::json!({
1316 "name": name,
1317 "implementation_id": implementation_id,
1318 "implementation_version": implementation_version,
1319 "public_schema": public_schema,
1320 "capabilities": capabilities,
1321 "trust_class": trust_class,
1322 });
1323 serde_json::json!({
1324 "binding_version": format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(&identity).expect("compatibility binding identity is serializable"))),
1325 "implementation_id": implementation_id,
1326 "implementation_version": implementation_version,
1327 "public_schema": public_schema,
1328 "fixed": {},
1329 "restrictions": {},
1330 "capabilities": capabilities,
1331 "trust_class": trust_class,
1332 "compatibility": true,
1333 })
1334}
1335
1336fn sanitize_metadata(value: Value) -> Value {
1337 if is_secret_reference(&value) {
1338 return serde_json::json!({
1339 "secret_ref": value["env"],
1340 });
1341 }
1342 match value {
1343 Value::Array(values) => Value::Array(values.into_iter().map(sanitize_metadata).collect()),
1344 Value::Object(values) => Value::Object(
1345 values
1346 .into_iter()
1347 .map(|(name, value)| (name, sanitize_metadata(value)))
1348 .collect(),
1349 ),
1350 value => value,
1351 }
1352}
1353
1354impl BindingRestrictions {
1355 fn kinds(&self) -> impl Iterator<Item = RestrictionKind> {
1356 let mut kinds = Vec::with_capacity(3);
1357 if !self.paths.is_empty() {
1358 kinds.push(RestrictionKind::Paths);
1359 }
1360 if !self.sources.is_empty() {
1361 kinds.push(RestrictionKind::Sources);
1362 }
1363 if self.max_output_bytes.is_some() {
1364 kinds.push(RestrictionKind::MaxOutputBytes);
1365 }
1366 kinds.into_iter()
1367 }
1368}
1369
1370fn toml_table_to_json(table: &toml::Table) -> Result<Value> {
1371 serde_json::to_value(table).context("serializing tool binding table")
1372}
1373
1374fn schema_properties(schema: &Value) -> Result<&serde_json::Map<String, Value>> {
1375 schema
1376 .get("properties")
1377 .and_then(Value::as_object)
1378 .context("schema properties must be an object")
1379}
1380
1381fn validate_fixed(schema: &Value, fixed: &Value) -> Result<()> {
1382 let properties = schema_properties(schema)?;
1383 let fixed = fixed
1384 .as_object()
1385 .context("fixed arguments must be an object")?;
1386 for (name, value) in fixed {
1387 let property = properties
1388 .get(name)
1389 .with_context(|| format!("fixed argument '{name}' is not in the input schema"))?;
1390 validate_schema_value(property, value, &format!("fixed.{name}"))?;
1391 }
1392 Ok(())
1393}
1394
1395fn derive_public_schema(schema: &Value, fixed: &toml::Table) -> Result<Value> {
1396 let mut public = schema.clone();
1397 let object = public
1398 .as_object_mut()
1399 .context("input schema must be an object")?;
1400 let properties = object
1401 .get_mut("properties")
1402 .and_then(Value::as_object_mut)
1403 .context("input schema properties must be an object")?;
1404 for name in fixed.keys() {
1405 ensure!(
1406 properties.remove(name).is_some(),
1407 "fixed argument '{name}' is not in the input schema"
1408 );
1409 }
1410 if let Some(required) = object.get_mut("required").and_then(Value::as_array_mut) {
1411 required.retain(|name| !name.as_str().is_some_and(|name| fixed.contains_key(name)));
1412 }
1413 Ok(public)
1414}
1415
1416fn validate_schema_value(schema: &Value, value: &Value, label: &str) -> Result<()> {
1419 if is_secret_reference(value) {
1420 ensure!(
1421 schema.get("x-secret").and_then(Value::as_bool) == Some(true),
1422 "{label} does not permit a secret reference"
1423 );
1424 return Ok(());
1425 }
1426 if let Some(expected) = schema.get("type").and_then(Value::as_str) {
1427 let matches = match expected {
1428 "object" => value.is_object(),
1429 "array" => value.is_array(),
1430 "string" => value.is_string(),
1431 "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
1432 "number" => value.is_number(),
1433 "boolean" => value.is_boolean(),
1434 "null" => value.is_null(),
1435 _ => bail!("{label} uses unsupported schema type '{expected}'"),
1436 };
1437 ensure!(matches, "{label} does not match schema type '{expected}'");
1438 }
1439 if let Some(choices) = schema.get("enum").and_then(Value::as_array) {
1440 ensure!(choices.contains(value), "{label} is not an allowed value");
1441 }
1442 if let Some(number) = value.as_f64() {
1443 if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) {
1444 ensure!(number >= minimum, "{label} is below its minimum");
1445 }
1446 if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) {
1447 ensure!(number <= maximum, "{label} exceeds its maximum");
1448 }
1449 }
1450 if let Some(string) = value.as_str() {
1451 let length = string.chars().count() as u64;
1452 if let Some(minimum) = schema.get("minLength").and_then(Value::as_u64) {
1453 ensure!(length >= minimum, "{label} is shorter than its minimum");
1454 }
1455 if let Some(maximum) = schema.get("maxLength").and_then(Value::as_u64) {
1456 ensure!(length <= maximum, "{label} exceeds its maximum length");
1457 }
1458 }
1459 if let Some(object) = value.as_object() {
1460 let properties = schema
1461 .get("properties")
1462 .and_then(Value::as_object)
1463 .cloned()
1464 .unwrap_or_default();
1465 if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) {
1466 ensure!(
1467 object.keys().all(|name| properties.contains_key(name)),
1468 "{label} contains an unknown field"
1469 );
1470 }
1471 if let Some(required) = schema.get("required").and_then(Value::as_array) {
1472 for name in required.iter().filter_map(Value::as_str) {
1473 ensure!(object.contains_key(name), "{label}.{name} is required");
1474 }
1475 }
1476 for (name, item) in object {
1477 if let Some(property) = properties.get(name) {
1478 validate_schema_value(property, item, &format!("{label}.{name}"))?;
1479 }
1480 }
1481 }
1482 Ok(())
1483}
1484
1485pub fn validate_binding_arguments(schema: &Value, arguments: &Value) -> Result<()> {
1488 validate_schema_value(schema, arguments, "arguments")
1489}
1490
1491fn is_secret_reference(value: &Value) -> bool {
1492 let Some(object) = value.as_object() else {
1493 return false;
1494 };
1495 object.len() == 1
1496 && object
1497 .get("env")
1498 .and_then(Value::as_str)
1499 .is_some_and(valid_environment_name)
1500}
1501
1502fn valid_environment_name(value: &str) -> bool {
1503 let mut chars = value.chars();
1504 chars
1505 .next()
1506 .is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
1507 && chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512 use super::*;
1513
1514 const RESOURCE: &str = r#"
1515schema_version = 1
1516[tool]
1517name = "release.read"
1518implementation = "builtin.scoped_file_read"
1519description = "Read release files"
1520[config]
1521root = "release"
1522[fixed]
1523max_bytes = 65536
1524[restrictions]
1525paths = ["release"]
1526max_output_bytes = 65536
1527"#;
1528
1529 #[test]
1530 fn resource_is_strict_validated_and_content_versioned() {
1531 let first = ToolBindingResource::parse(RESOURCE).unwrap();
1532 let second = ToolBindingResource::parse(&format!("\n{RESOURCE}\n# comment\n")).unwrap();
1533 assert_eq!(first.tool.name, "release.read");
1534 assert_eq!(
1535 first.resource_version().unwrap(),
1536 second.resource_version().unwrap()
1537 );
1538 assert!(ToolBindingResource::parse(&RESOURCE.replace(
1539 "description = \"Read release files\"",
1540 "description = \"Read release files\"\nunknown = true"
1541 ))
1542 .is_err());
1543 let workspace_root = RESOURCE
1544 .replace("release.read", "project.read")
1545 .replace("release", ".");
1546 assert!(ToolBindingResource::parse(&workspace_root).is_ok());
1547 }
1548
1549 #[test]
1550 fn resource_rejects_reserved_names_and_invalid_restrictions() {
1551 for invalid in [
1552 RESOURCE.replace("release.read", "workspace.read"),
1553 RESOURCE.replace("paths = [\"release\"]", "paths = [\"../release\"]"),
1554 RESOURCE.replace(
1555 "paths = [\"release\"]",
1556 "paths = [\"release\", \"./release\"]",
1557 ),
1558 RESOURCE.replace("max_output_bytes = 65536", "max_output_bytes = 0"),
1559 RESOURCE.replace("schema_version = 1", "schema_version = 2"),
1560 ] {
1561 assert!(ToolBindingResource::parse(&invalid).is_err(), "{invalid}");
1562 }
1563 }
1564
1565 #[test]
1566 fn host_authority_cannot_enroll_paths_outside_workspace() {
1567 let workspace = tempfile::TempDir::new().unwrap();
1568 let outside = tempfile::TempDir::new().unwrap();
1569 std::fs::create_dir(workspace.path().join("allowed")).unwrap();
1570 let mut authority =
1571 HostToolAuthority::new(workspace.path(), vec![Capability::ReadOnly]).unwrap();
1572 authority.enroll_path(Path::new("allowed")).unwrap();
1573 assert!(authority.enroll_path(outside.path()).is_err());
1574 assert_eq!(authority.paths().len(), 1);
1575 }
1576
1577 #[test]
1578 fn descriptor_validation_rejects_ambiguous_contracts() {
1579 let mut descriptor = ToolImplementationDescriptor {
1580 id: "builtin.reader".into(),
1581 version: "1".into(),
1582 implementation_description: "Read scoped files".into(),
1583 default_public_description: "Read a file".into(),
1584 config_schema: serde_json::json!({"type":"object"}),
1585 input_schema: serde_json::json!({"type":"object"}),
1586 capabilities: vec![Capability::ReadOnly],
1587 supported_restrictions: vec![RestrictionKind::Paths],
1588 trust_class: ToolTrustClass::Builtin,
1589 };
1590 descriptor.validate().unwrap();
1591 descriptor.capabilities.push(Capability::ReadOnly);
1592 assert!(descriptor.validate().is_err());
1593 descriptor.capabilities.pop();
1594 descriptor.input_schema = serde_json::json!({"type":"string"});
1595 assert!(descriptor.validate().is_err());
1596 }
1597
1598 #[test]
1599 fn history_metadata_preserves_secret_references_without_values() {
1600 let sanitized = sanitize_metadata(serde_json::json!({
1601 "nested": {"token": {"env": "ISSUE_TOKEN"}},
1602 "plain": "visible"
1603 }));
1604 assert_eq!(sanitized["nested"]["token"]["secret_ref"], "ISSUE_TOKEN");
1605 assert!(sanitized.to_string().contains("ISSUE_TOKEN"));
1606 assert!(!sanitized.to_string().contains("\"env\""));
1607
1608 let resource = ResolvedToolResource {
1609 path: PathBuf::from("secret.toml"),
1610 scope: ResourceScope::Workspace,
1611 resource_version: "resource-v1".into(),
1612 binding: ResolvedToolBinding {
1613 name: "secret.tool".into(),
1614 description: "Secret fixture".into(),
1615 implementation_description: "Secret fixture implementation".into(),
1616 implementation_id: "rust.secret.fixture".into(),
1617 implementation_version: "1".into(),
1618 trust_class: ToolTrustClass::Compiled,
1619 capabilities: vec![],
1620 public_schema: serde_json::json!({"type":"object","properties":{}}),
1621 config: serde_json::json!({"token":{"env":"ISSUE_TOKEN"}}),
1622 fixed: serde_json::json!({"header":{"env":"HEADER_TOKEN"}}),
1623 restrictions: BindingRestrictions::default(),
1624 binding_version: "binding-v1".into(),
1625 remote_metadata: None,
1626 },
1627 };
1628 let inspected = inspection_value(&resource).unwrap();
1629 assert_eq!(
1630 inspected["binding"]["config"]["token"]["secret_ref"],
1631 "ISSUE_TOKEN"
1632 );
1633 assert_eq!(
1634 inspected["binding"]["fixed"]["header"]["secret_ref"],
1635 "HEADER_TOKEN"
1636 );
1637 assert!(!inspected.to_string().contains("\"env\""));
1638 }
1639}