Skip to main content

harn_vm/
tool_annotations.rs

1//! Tool annotations — the single source of truth for tool semantics.
2//!
3//! These types describe what a tool does at a semantic level. The VM
4//! consumes them to make policy decisions (read-only vs mutating, which
5//! argument holds the workspace path, which aliases to normalize, etc.)
6//! without hardcoding tool names or file-extension lists. Pipeline
7//! authors declare a `ToolAnnotations` value per tool in their
8//! `CapabilityPolicy.tool_annotations` registry; everything downstream
9//! is driven by that declaration.
10//!
11//! This alignment is ACP-compliant: `ToolKind` matches the canonical
12//! tool-kind vocabulary from the [Agent Client Protocol schema]
13//! (https://agentclientprotocol.com/protocol/schema) one-for-one.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19/// Canonical tool-kind vocabulary. Matches the ACP `ToolKind` enum so
20/// harn-cli's ACP server can forward the value unchanged in
21/// `sessionUpdate` variants.
22///
23/// The VM treats `Read`, `Search`, `Think`, and `Fetch` as read-only
24/// for concurrent-dispatch purposes. `Other` is intentionally NOT
25/// treated as read-only — unannotated tools should not slip through
26/// as auto-approved by default (fail-safe).
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ToolKind {
30    /// Reads file/workspace content without mutation.
31    Read,
32    /// Mutates workspace content (write, patch, edit).
33    Edit,
34    /// Removes content irreversibly.
35    Delete,
36    /// Relocates or renames content.
37    Move,
38    /// Queries indexes or directories; no mutation.
39    Search,
40    /// Runs a subprocess or a shell command.
41    Execute,
42    /// Pure reasoning/thought invocation, no side effects.
43    Think,
44    /// Retrieves remote content (HTTP, MCP fetch, etc.).
45    Fetch,
46    /// Anything that doesn't map cleanly into the canonical kinds.
47    /// Not treated as read-only — the fail-safe default.
48    #[default]
49    Other,
50}
51
52impl ToolKind {
53    pub const ALL: [Self; 9] = [
54        Self::Read,
55        Self::Edit,
56        Self::Delete,
57        Self::Move,
58        Self::Search,
59        Self::Execute,
60        Self::Think,
61        Self::Fetch,
62        Self::Other,
63    ];
64
65    /// Read-only tools can dispatch concurrently without risking
66    /// conflicting state mutations. `Other` is excluded by design —
67    /// unannotated tools must not auto-approve as read-only.
68    pub fn is_read_only(&self) -> bool {
69        matches!(self, Self::Read | Self::Search | Self::Think | Self::Fetch)
70    }
71
72    /// Coarse mutation-classification string used in tool-call
73    /// telemetry and pre/post bridge payloads. Derived directly from
74    /// the kind — the VM no longer guesses from tool names.
75    pub fn mutation_class(&self) -> &'static str {
76        match self {
77            Self::Read | Self::Search | Self::Think | Self::Fetch => "read_only",
78            Self::Edit => "workspace_write",
79            Self::Delete | Self::Move => "destructive",
80            Self::Execute => "ambient_side_effect",
81            Self::Other => "other",
82        }
83    }
84}
85
86/// Rough side-effect taxonomy for the capability-ceiling check.
87#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum SideEffectLevel {
90    /// No side effect declared (conservative default; permission logic
91    /// treats this as "unknown → deny unless explicitly allowed").
92    #[default]
93    None,
94    /// Pure reads only.
95    ReadOnly,
96    /// Writes to workspace files.
97    WorkspaceWrite,
98    /// Runs subprocesses.
99    ProcessExec,
100    /// Reaches external services over the network.
101    Network,
102}
103
104impl SideEffectLevel {
105    pub const ALL: [Self; 5] = [
106        Self::None,
107        Self::ReadOnly,
108        Self::WorkspaceWrite,
109        Self::ProcessExec,
110        Self::Network,
111    ];
112
113    /// Numeric rank used by the policy intersector and side-effect
114    /// ceiling check. Higher rank ⇒ more invasive.
115    pub fn rank(&self) -> usize {
116        match self {
117            Self::None => 0,
118            Self::ReadOnly => 1,
119            Self::WorkspaceWrite => 2,
120            Self::ProcessExec => 3,
121            Self::Network => 4,
122        }
123    }
124
125    /// Short string used in policy documents, bridge payloads, and
126    /// error messages. Stable wire identifier.
127    pub fn as_str(&self) -> &'static str {
128        match self {
129            Self::None => "none",
130            Self::ReadOnly => "read_only",
131            Self::WorkspaceWrite => "workspace_write",
132            Self::ProcessExec => "process_exec",
133            Self::Network => "network",
134        }
135    }
136
137    /// Parse from the stable string used in policy documents. Unknown
138    /// values deserialize to `None` (the conservative default).
139    pub fn parse(value: &str) -> Self {
140        match value {
141            "none" => Self::None,
142            "read_only" => Self::ReadOnly,
143            "workspace_write" => Self::WorkspaceWrite,
144            "process_exec" => Self::ProcessExec,
145            "network" => Self::Network,
146            _ => Self::None,
147        }
148    }
149}
150
151/// Declarative description of a tool's argument shape. The VM uses
152/// this to:
153///
154/// - resolve `ToolArgConstraint` lookups (`path_params`),
155/// - rewrite high-level aliases to canonical keys without any
156///   per-tool hardcoded branches (`arg_aliases`),
157/// - validate presence of required arguments at the dispatch boundary
158///   (`required`).
159#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
160#[serde(default)]
161pub struct ToolArgSchema {
162    /// Argument keys whose values are workspace-relative paths.
163    /// First matching key whose value is a string wins.
164    pub path_params: Vec<String>,
165    /// Alias → canonical key. When a tool call arrives with an alias
166    /// in its argument object, the VM rewrites the key to the canonical
167    /// form before dispatch (generic; no tool-name branches).
168    pub arg_aliases: BTreeMap<String, String>,
169    /// Argument keys that must be present (non-null) on every call.
170    pub required: Vec<String>,
171}
172
173/// Full annotations for one tool. Pipelines populate one of these per
174/// tool in the capability-policy registry; the VM consults the registry
175/// on every tool call.
176#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
177#[serde(default)]
178pub struct ToolAnnotations {
179    /// ACP-aligned tool-kind classification.
180    pub kind: ToolKind,
181    /// Required side-effect level for the capability ceiling check.
182    pub side_effect_level: SideEffectLevel,
183    /// Argument shape declarations.
184    pub arg_schema: ToolArgSchema,
185    /// Capability operations requested by this tool (e.g.
186    /// `"workspace": ["read_text", "list"]`).
187    pub capabilities: BTreeMap<String, Vec<String>>,
188    /// True when the tool may return only a handle/reference to a large
189    /// output artifact instead of inline output. Execute tools with this
190    /// flag must also declare an inspection route.
191    pub emits_artifacts: bool,
192    /// Tool names that can inspect artifacts/results emitted by this tool.
193    pub result_readers: Vec<String>,
194    /// Explicit escape hatch for tools whose results are always complete
195    /// inline, even though they are execute-like.
196    pub inline_result: bool,
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn tool_kind_serde_roundtrip() {
205        for (kind, expected) in [
206            (ToolKind::Read, "\"read\""),
207            (ToolKind::Edit, "\"edit\""),
208            (ToolKind::Delete, "\"delete\""),
209            (ToolKind::Move, "\"move\""),
210            (ToolKind::Search, "\"search\""),
211            (ToolKind::Execute, "\"execute\""),
212            (ToolKind::Think, "\"think\""),
213            (ToolKind::Fetch, "\"fetch\""),
214            (ToolKind::Other, "\"other\""),
215        ] {
216            let encoded = serde_json::to_string(&kind).unwrap();
217            assert_eq!(encoded, expected);
218            let decoded: ToolKind = serde_json::from_str(expected).unwrap();
219            assert_eq!(decoded, kind);
220        }
221    }
222
223    #[test]
224    fn only_read_search_think_fetch_are_read_only() {
225        assert!(ToolKind::Read.is_read_only());
226        assert!(ToolKind::Search.is_read_only());
227        assert!(ToolKind::Think.is_read_only());
228        assert!(ToolKind::Fetch.is_read_only());
229        // Fail-safe: Other is NOT read-only.
230        assert!(!ToolKind::Other.is_read_only());
231        assert!(!ToolKind::Edit.is_read_only());
232        assert!(!ToolKind::Delete.is_read_only());
233        assert!(!ToolKind::Move.is_read_only());
234        assert!(!ToolKind::Execute.is_read_only());
235    }
236
237    #[test]
238    fn mutation_class_derived_from_kind() {
239        assert_eq!(ToolKind::Read.mutation_class(), "read_only");
240        assert_eq!(ToolKind::Search.mutation_class(), "read_only");
241        assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
242        assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
243        assert_eq!(ToolKind::Move.mutation_class(), "destructive");
244        assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
245        assert_eq!(ToolKind::Other.mutation_class(), "other");
246    }
247
248    #[test]
249    fn side_effect_level_round_trip() {
250        for level in [
251            SideEffectLevel::None,
252            SideEffectLevel::ReadOnly,
253            SideEffectLevel::WorkspaceWrite,
254            SideEffectLevel::ProcessExec,
255            SideEffectLevel::Network,
256        ] {
257            assert_eq!(SideEffectLevel::parse(level.as_str()), level);
258            let encoded = serde_json::to_string(&level).unwrap();
259            let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
260            assert_eq!(decoded, level);
261        }
262    }
263
264    #[test]
265    fn side_effect_level_rank_orders() {
266        assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
267        assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
268        assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
269        assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
270    }
271
272    #[test]
273    fn arg_schema_defaults_empty() {
274        let schema = ToolArgSchema::default();
275        assert!(schema.path_params.is_empty());
276        assert!(schema.arg_aliases.is_empty());
277        assert!(schema.required.is_empty());
278    }
279
280    #[test]
281    fn annotations_default_result_routes_empty() {
282        let annotations = ToolAnnotations::default();
283        assert!(!annotations.emits_artifacts);
284        assert!(annotations.result_readers.is_empty());
285        assert!(!annotations.inline_result);
286    }
287}