Skip to main content

navi_core/tool/
metadata.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5/// Rich metadata for a tool definition.
6///
7/// Provides the harness with enough information for routing, policy, UI,
8/// concurrency, traces, verifiers, and search — without relying solely on
9/// `ToolKind` for security decisions.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ToolMetadata {
12    /// Semantic namespace for grouping tools (e.g. "file", "code", "process", "mcp").
13    #[serde(default)]
14    pub namespace: String,
15
16    /// Risk level hint for the harness: how dangerous is this tool by default.
17    #[serde(default)]
18    pub risk: ToolRisk,
19
20    /// Whether this tool only reads state (never mutates repo, memory, or config).
21    #[serde(default)]
22    pub is_read_only: bool,
23
24    /// Whether this tool is safe to call concurrently with other tools.
25    #[serde(default)]
26    pub is_concurrency_safe: bool,
27
28    /// Whether this tool supports streaming output via events.
29    #[serde(default)]
30    pub supports_streaming: bool,
31
32    /// Whether this tool can process multiple items in a single call.
33    #[serde(default)]
34    pub supports_batch: bool,
35
36    /// Whether this tool supports rollback (undo of its effects).
37    #[serde(default)]
38    pub supports_rollback: bool,
39
40    /// Maximum output bytes the tool produces before truncation.
41    #[serde(default)]
42    pub max_output_bytes: Option<usize>,
43
44    /// Visibility/exposure mode for tool registry routing.
45    #[serde(default)]
46    pub exposure: ToolExposure,
47
48    /// Capabilities required or provided by this tool.
49    #[serde(default)]
50    pub capabilities: Vec<String>,
51
52    /// Verifier spec hint: which verifier to run after this tool completes.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub verifier: Option<String>,
55
56    /// Example invocations for the model (shown in tool.search results).
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub examples: Vec<Value>,
59
60    /// Tags for search and categorization.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub tags: Vec<String>,
63
64    /// Arbitrary extended metadata (future-proof).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub extensions: Option<HashMap<String, Value>>,
67}
68
69impl ToolMetadata {
70    /// Creates a default read-only, safe, simple tool.
71    pub fn read_only() -> Self {
72        Self {
73            is_read_only: true,
74            is_concurrency_safe: true,
75            ..Self::default()
76        }
77    }
78
79    /// Creates a read tool (file reads, searches).
80    pub fn reader(namespace: &str, tags: &[&str]) -> Self {
81        Self {
82            namespace: namespace.to_string(),
83            risk: ToolRisk::Low,
84            is_read_only: true,
85            is_concurrency_safe: true,
86            exposure: ToolExposure::Direct,
87            capabilities: vec!["repo.read".to_string()],
88            tags: tags.iter().map(|s| s.to_string()).collect(),
89            ..Self::default()
90        }
91    }
92
93    /// Creates a write tool (file writes, edits, patches).
94    pub fn writer(namespace: &str, tags: &[&str]) -> Self {
95        Self {
96            namespace: namespace.to_string(),
97            risk: ToolRisk::Medium,
98            is_read_only: false,
99            is_concurrency_safe: false,
100            supports_rollback: true,
101            exposure: ToolExposure::Direct,
102            capabilities: vec!["repo.write".to_string()],
103            tags: tags.iter().map(|s| s.to_string()).collect(),
104            ..Self::default()
105        }
106    }
107
108    /// Creates a command/execution tool (bash, process).
109    pub fn command(namespace: &str, tags: &[&str]) -> Self {
110        Self {
111            namespace: namespace.to_string(),
112            risk: ToolRisk::High,
113            is_read_only: false,
114            is_concurrency_safe: false,
115            exposure: ToolExposure::Direct,
116            capabilities: vec!["shell.exec".to_string()],
117            tags: tags.iter().map(|s| s.to_string()).collect(),
118            ..Self::default()
119        }
120    }
121
122    /// Creates a tool that should be deferred (not shown by default).
123    pub fn deferred(namespace: &str, risk: ToolRisk, tags: &[&str]) -> Self {
124        Self {
125            namespace: namespace.to_string(),
126            risk,
127            exposure: ToolExposure::Deferred,
128            tags: tags.iter().map(|s| s.to_string()).collect(),
129            ..Self::default()
130        }
131    }
132
133    /// Creates an internal/hidden tool.
134    pub fn internal(namespace: &str, tags: &[&str]) -> Self {
135        Self {
136            namespace: namespace.to_string(),
137            risk: ToolRisk::Low,
138            is_read_only: true,
139            is_concurrency_safe: true,
140            exposure: ToolExposure::Internal,
141            tags: tags.iter().map(|s| s.to_string()).collect(),
142            ..Self::default()
143        }
144    }
145}
146
147impl Default for ToolMetadata {
148    fn default() -> Self {
149        Self {
150            namespace: String::new(),
151            risk: ToolRisk::Unspecified,
152            is_read_only: false,
153            is_concurrency_safe: false,
154            supports_streaming: false,
155            supports_batch: false,
156            supports_rollback: false,
157            max_output_bytes: None,
158            exposure: ToolExposure::Direct,
159            capabilities: Vec::new(),
160            verifier: None,
161            examples: Vec::new(),
162            tags: Vec::new(),
163            extensions: None,
164        }
165    }
166}
167
168impl ToolMetadata {
169    /// Returns true if this metadata is the default (unset) value.
170    /// Used by ToolExecutor to detect tools that need builtin metadata injection.
171    pub fn is_default(&self) -> bool {
172        self.namespace.is_empty()
173            && self.risk == ToolRisk::Unspecified
174            && !self.is_read_only
175            && !self.is_concurrency_safe
176            && self.capabilities.is_empty()
177            && self.tags.is_empty()
178    }
179
180    /// Builder: sets exposure mode.
181    pub fn with_exposure(mut self, exposure: ToolExposure) -> Self {
182        self.exposure = exposure;
183        self
184    }
185
186    /// Builder: sets capabilities.
187    pub fn with_capability(mut self, caps: &[&str]) -> Self {
188        self.capabilities = caps.iter().map(|s| s.to_string()).collect();
189        self
190    }
191
192    /// Builder: sets max output bytes.
193    pub fn with_max_output(mut self, bytes: usize) -> Self {
194        self.max_output_bytes = Some(bytes);
195        self
196    }
197
198    /// Builder: adds tags.
199    pub fn with_tags(mut self, tags: &[&str]) -> Self {
200        self.tags = tags.iter().map(|s| s.to_string()).collect();
201        self
202    }
203}
204
205/// Risk level hint for a tool.
206///
207/// Used by the policy system to decide whether a tool invocation needs
208/// approval, sandboxing, or rollback capabilities.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210pub enum ToolRisk {
211    /// Risk not yet classified.
212    Unspecified,
213    /// Minimal risk (e.g. read tools, info tools).
214    Low,
215    /// Moderate risk (e.g. file writes, git operations).
216    Medium,
217    /// High risk (e.g. shell commands, network, MCP execution).
218    High,
219    /// Critical risk (e.g. credential access, guarded commands, privilege escalation).
220    Critical,
221}
222
223impl Default for ToolRisk {
224    fn default() -> Self {
225        Self::Unspecified
226    }
227}
228
229/// Exposure mode for a tool in the registry.
230///
231/// Controls whether a tool is visible to the model by default or needs to be
232/// discovered through `tool.search`.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234pub enum ToolExposure {
235    /// Tool is visible in the main prompt by default.
236    Direct,
237    /// Tool is registered but not visible; can be discovered via `tool.search`.
238    Deferred,
239    /// Tool is hidden from the model entirely; only usable by internal systems.
240    Hidden,
241    /// Tool is visible only to the model (not shown in UI).
242    #[serde(rename = "model_only")]
243    ModelOnly,
244    /// Tool is for internal harness use only (not visible to model or user).
245    Internal,
246}
247
248impl Default for ToolExposure {
249    fn default() -> Self {
250        Self::Direct
251    }
252}
253
254/// Set of capabilities that a tool may require or provide.
255///
256/// These are used by the capability ledger (post-parity) and by exposure
257/// routing.
258pub mod capabilities {
259    pub const REPO_READ: &str = "repo.read";
260    pub const REPO_WRITE: &str = "repo.write";
261    pub const REPO_WRITE_SRC: &str = "repo.write.src";
262    pub const REPO_WRITE_TESTS: &str = "repo.write.tests";
263    pub const REPO_WRITE_DOCS: &str = "repo.write.docs";
264    pub const REPO_WRITE_CI: &str = "repo.write.ci";
265    pub const REPO_WRITE_LOCKFILE: &str = "repo.write.lockfile";
266    pub const SHELL_EXEC: &str = "shell.exec";
267    pub const SHELL_PRIVILEGED: &str = "shell.privileged";
268    pub const NETWORK_GITHUB: &str = "network.github";
269    pub const NETWORK_PACKAGE: &str = "network.package";
270    pub const NETWORK_GENERAL: &str = "network.general";
271    pub const SECRETS_READ: &str = "secrets.read";
272    pub const MCP_ACCESS: &str = "mcp.access";
273    pub const AGENT_SPAWN: &str = "agent.spawn";
274    pub const MEMORY_READ: &str = "memory.read";
275    pub const MEMORY_WRITE: &str = "memory.write";
276    pub const VERIFIER_RUN: &str = "verifier.run";
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use serde_json::json;
283
284    #[test]
285    fn metadata_default_serde_roundtrip() {
286        let m = ToolMetadata::default();
287        let json = serde_json::to_value(&m).unwrap();
288        let restored: ToolMetadata = serde_json::from_value(json).unwrap();
289        assert_eq!(m.risk, restored.risk);
290        assert_eq!(m.exposure, restored.exposure);
291        assert_eq!(m.is_read_only, restored.is_read_only);
292    }
293
294    #[test]
295    fn metadata_read_only_preset() {
296        let m = ToolMetadata::read_only();
297        assert!(m.is_read_only);
298        assert!(m.is_concurrency_safe);
299    }
300
301    #[test]
302    fn metadata_reader_preset() {
303        let m = ToolMetadata::reader("file", &["read", "search"]);
304        assert!(m.is_read_only);
305        assert!(m.is_concurrency_safe);
306        assert_eq!(m.risk, ToolRisk::Low);
307        assert_eq!(m.exposure, ToolExposure::Direct);
308        assert_eq!(m.namespace, "file");
309        assert!(m.capabilities.contains(&"repo.read".to_string()));
310    }
311
312    #[test]
313    fn metadata_writer_preset() {
314        let m = ToolMetadata::writer("file", &["edit", "patch"]);
315        assert!(!m.is_read_only);
316        assert!(!m.is_concurrency_safe);
317        assert!(m.supports_rollback);
318        assert_eq!(m.risk, ToolRisk::Medium);
319    }
320
321    #[test]
322    fn metadata_command_preset() {
323        let m = ToolMetadata::command("process", &["shell", "bash"]);
324        assert_eq!(m.risk, ToolRisk::High);
325        assert!(!m.is_concurrency_safe);
326        assert!(m.capabilities.contains(&"shell.exec".to_string()));
327    }
328
329    #[test]
330    fn metadata_deferred_preset() {
331        let m = ToolMetadata::deferred("mcp", ToolRisk::Medium, &["mcp", "external"]);
332        assert_eq!(m.exposure, ToolExposure::Deferred);
333        assert_eq!(m.risk, ToolRisk::Medium);
334    }
335
336    #[test]
337    fn metadata_internal_preset() {
338        let m = ToolMetadata::internal("runtime", &["internal"]);
339        assert_eq!(m.exposure, ToolExposure::Internal);
340        assert!(m.is_read_only);
341        assert!(m.is_concurrency_safe);
342    }
343
344    #[test]
345    fn metadata_full_serialization() {
346        let m = ToolMetadata {
347            namespace: "test".to_string(),
348            risk: ToolRisk::High,
349            is_read_only: false,
350            is_concurrency_safe: false,
351            supports_streaming: true,
352            supports_batch: false,
353            supports_rollback: true,
354            max_output_bytes: Some(65536),
355            exposure: ToolExposure::Direct,
356            capabilities: vec!["test.cap".to_string()],
357            verifier: Some("verify.test".to_string()),
358            examples: vec![json!({"key": "value"})],
359            tags: vec!["tag1".to_string(), "tag2".to_string()],
360            extensions: None,
361        };
362        let v = serde_json::to_value(&m).unwrap();
363        let restored: ToolMetadata = serde_json::from_value(v).unwrap();
364        assert_eq!(restored.namespace, "test");
365        assert_eq!(restored.risk, ToolRisk::High);
366        assert_eq!(restored.max_output_bytes, Some(65536));
367        assert_eq!(restored.verifier, Some("verify.test".to_string()));
368        assert_eq!(restored.tags.len(), 2);
369    }
370
371    #[test]
372    fn risk_default_is_unspecified() {
373        assert_eq!(ToolRisk::default(), ToolRisk::Unspecified);
374    }
375
376    #[test]
377    fn exposure_default_is_direct() {
378        assert_eq!(ToolExposure::default(), ToolExposure::Direct);
379    }
380}