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 /// Read-only tools can dispatch concurrently without risking
54 /// conflicting state mutations. `Other` is excluded by design —
55 /// unannotated tools must not auto-approve as read-only.
56 pub fn is_read_only(&self) -> bool {
57 matches!(self, Self::Read | Self::Search | Self::Think | Self::Fetch)
58 }
59
60 /// Coarse mutation-classification string used in tool-call
61 /// telemetry and pre/post bridge payloads. Derived directly from
62 /// the kind — the VM no longer guesses from tool names.
63 pub fn mutation_class(&self) -> &'static str {
64 match self {
65 Self::Read | Self::Search | Self::Think | Self::Fetch => "read_only",
66 Self::Edit => "workspace_write",
67 Self::Delete | Self::Move => "destructive",
68 Self::Execute => "ambient_side_effect",
69 Self::Other => "other",
70 }
71 }
72}
73
74/// Rough side-effect taxonomy for the capability-ceiling check.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SideEffectLevel {
78 /// No side effect declared (conservative default; permission logic
79 /// treats this as "unknown → deny unless explicitly allowed").
80 #[default]
81 None,
82 /// Pure reads only.
83 ReadOnly,
84 /// Writes to workspace files.
85 WorkspaceWrite,
86 /// Runs subprocesses.
87 ProcessExec,
88 /// Reaches external services over the network.
89 Network,
90}
91
92impl SideEffectLevel {
93 /// Numeric rank used by the policy intersector and side-effect
94 /// ceiling check. Higher rank ⇒ more invasive.
95 pub fn rank(&self) -> usize {
96 match self {
97 Self::None => 0,
98 Self::ReadOnly => 1,
99 Self::WorkspaceWrite => 2,
100 Self::ProcessExec => 3,
101 Self::Network => 4,
102 }
103 }
104
105 /// Short string used in policy documents, bridge payloads, and
106 /// error messages. Stable wire identifier.
107 pub fn as_str(&self) -> &'static str {
108 match self {
109 Self::None => "none",
110 Self::ReadOnly => "read_only",
111 Self::WorkspaceWrite => "workspace_write",
112 Self::ProcessExec => "process_exec",
113 Self::Network => "network",
114 }
115 }
116
117 /// Parse from the stable string used in policy documents. Unknown
118 /// values deserialize to `None` (the conservative default).
119 pub fn parse(value: &str) -> Self {
120 match value {
121 "none" => Self::None,
122 "read_only" => Self::ReadOnly,
123 "workspace_write" => Self::WorkspaceWrite,
124 "process_exec" => Self::ProcessExec,
125 "network" => Self::Network,
126 _ => Self::None,
127 }
128 }
129}
130
131/// Declarative description of a tool's argument shape. The VM uses
132/// this to:
133///
134/// - resolve `ToolArgConstraint` lookups (`path_params`),
135/// - rewrite high-level aliases to canonical keys without any
136/// per-tool hardcoded branches (`arg_aliases`),
137/// - validate presence of required arguments at the dispatch boundary
138/// (`required`).
139#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
140#[serde(default)]
141pub struct ToolArgSchema {
142 /// Argument keys whose values are workspace-relative paths.
143 /// First matching key whose value is a string wins.
144 pub path_params: Vec<String>,
145 /// Alias → canonical key. When a tool call arrives with an alias
146 /// in its argument object, the VM rewrites the key to the canonical
147 /// form before dispatch (generic; no tool-name branches).
148 pub arg_aliases: BTreeMap<String, String>,
149 /// Argument keys that must be present (non-null) on every call.
150 pub required: Vec<String>,
151}
152
153/// Full annotations for one tool. Pipelines populate one of these per
154/// tool in the capability-policy registry; the VM consults the registry
155/// on every tool call.
156#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct ToolAnnotations {
159 /// ACP-aligned tool-kind classification.
160 pub kind: ToolKind,
161 /// Required side-effect level for the capability ceiling check.
162 pub side_effect_level: SideEffectLevel,
163 /// Argument shape declarations.
164 pub arg_schema: ToolArgSchema,
165 /// Capability operations requested by this tool (e.g.
166 /// `"workspace": ["read_text", "list"]`).
167 pub capabilities: BTreeMap<String, Vec<String>>,
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn tool_kind_serde_roundtrip() {
176 for (kind, expected) in [
177 (ToolKind::Read, "\"read\""),
178 (ToolKind::Edit, "\"edit\""),
179 (ToolKind::Delete, "\"delete\""),
180 (ToolKind::Move, "\"move\""),
181 (ToolKind::Search, "\"search\""),
182 (ToolKind::Execute, "\"execute\""),
183 (ToolKind::Think, "\"think\""),
184 (ToolKind::Fetch, "\"fetch\""),
185 (ToolKind::Other, "\"other\""),
186 ] {
187 let encoded = serde_json::to_string(&kind).unwrap();
188 assert_eq!(encoded, expected);
189 let decoded: ToolKind = serde_json::from_str(expected).unwrap();
190 assert_eq!(decoded, kind);
191 }
192 }
193
194 #[test]
195 fn only_read_search_think_fetch_are_read_only() {
196 assert!(ToolKind::Read.is_read_only());
197 assert!(ToolKind::Search.is_read_only());
198 assert!(ToolKind::Think.is_read_only());
199 assert!(ToolKind::Fetch.is_read_only());
200 // Fail-safe: Other is NOT read-only.
201 assert!(!ToolKind::Other.is_read_only());
202 assert!(!ToolKind::Edit.is_read_only());
203 assert!(!ToolKind::Delete.is_read_only());
204 assert!(!ToolKind::Move.is_read_only());
205 assert!(!ToolKind::Execute.is_read_only());
206 }
207
208 #[test]
209 fn mutation_class_derived_from_kind() {
210 assert_eq!(ToolKind::Read.mutation_class(), "read_only");
211 assert_eq!(ToolKind::Search.mutation_class(), "read_only");
212 assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
213 assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
214 assert_eq!(ToolKind::Move.mutation_class(), "destructive");
215 assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
216 assert_eq!(ToolKind::Other.mutation_class(), "other");
217 }
218
219 #[test]
220 fn side_effect_level_round_trip() {
221 for level in [
222 SideEffectLevel::None,
223 SideEffectLevel::ReadOnly,
224 SideEffectLevel::WorkspaceWrite,
225 SideEffectLevel::ProcessExec,
226 SideEffectLevel::Network,
227 ] {
228 assert_eq!(SideEffectLevel::parse(level.as_str()), level);
229 let encoded = serde_json::to_string(&level).unwrap();
230 let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
231 assert_eq!(decoded, level);
232 }
233 }
234
235 #[test]
236 fn side_effect_level_rank_orders() {
237 assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
238 assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
239 assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
240 assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
241 }
242
243 #[test]
244 fn arg_schema_defaults_empty() {
245 let schema = ToolArgSchema::default();
246 assert!(schema.path_params.is_empty());
247 assert!(schema.arg_aliases.is_empty());
248 assert!(schema.required.is_empty());
249 }
250}