Skip to main content

context_harness/
tool_binding.rs

1//! Declarative tool-binding contracts from SPEC-0023.
2//!
3//! This module is deliberately side-effect free: parsing resources and registering
4//! trusted factories does not load scripts, start processes, open a database, or
5//! resolve secrets.
6
7use 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/// A versioned standalone tool resource.
30#[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    /// Hash parsed, default-expanded content. Implementation and derived-schema
126    /// identity are added later when the resource is resolved against a catalog.
127    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
135/// Choose tool resource paths using the same explicit-config isolation as agents.
136pub 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
190/// Load sorted layers and replace whole resources only when explicitly requested.
191pub 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/// Immutable, side-effect-free metadata supplied by a trusted implementation.
352#[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/// Host-issued authority. Resource declarations can only narrow these grants.
442#[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
511/// Owned input to a trusted implementation factory.
512pub struct ToolBindingRequest {
513    pub resource: ToolBindingResource,
514    pub resolved: ResolvedToolBinding,
515    pub authority: Arc<HostToolAuthority>,
516}
517
518/// Trusted executable registration. Binding may initialize local resources but
519/// discovery and descriptor access remain side-effect free.
520#[async_trait]
521pub trait ToolImplementationFactory: Send + Sync {
522    fn descriptor(&self) -> &ToolImplementationDescriptor;
523    /// Implementation-specific checks that require no execution or secret access.
524    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
1065/// Backwards-compatible name for callers that only inspect descriptors.
1066pub 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
1416/// Validate the strict schema subset used by built-in descriptors. Factories
1417/// remain responsible for semantic checks and any additional Draft 2020-12 keywords.
1418fn 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
1485/// Validate arguments against the strict, side-effect-free schema subset used
1486/// by declarative binding descriptors.
1487pub 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}