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 /// True when the tool may return only a handle/reference to a large
169 /// output artifact instead of inline output. Execute tools with this
170 /// flag must also declare an inspection route.
171 pub emits_artifacts: bool,
172 /// Tool names that can inspect artifacts/results emitted by this tool.
173 pub result_readers: Vec<String>,
174 /// Explicit escape hatch for tools whose results are always complete
175 /// inline, even though they are execute-like.
176 pub inline_result: bool,
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn tool_kind_serde_roundtrip() {
185 for (kind, expected) in [
186 (ToolKind::Read, "\"read\""),
187 (ToolKind::Edit, "\"edit\""),
188 (ToolKind::Delete, "\"delete\""),
189 (ToolKind::Move, "\"move\""),
190 (ToolKind::Search, "\"search\""),
191 (ToolKind::Execute, "\"execute\""),
192 (ToolKind::Think, "\"think\""),
193 (ToolKind::Fetch, "\"fetch\""),
194 (ToolKind::Other, "\"other\""),
195 ] {
196 let encoded = serde_json::to_string(&kind).unwrap();
197 assert_eq!(encoded, expected);
198 let decoded: ToolKind = serde_json::from_str(expected).unwrap();
199 assert_eq!(decoded, kind);
200 }
201 }
202
203 #[test]
204 fn only_read_search_think_fetch_are_read_only() {
205 assert!(ToolKind::Read.is_read_only());
206 assert!(ToolKind::Search.is_read_only());
207 assert!(ToolKind::Think.is_read_only());
208 assert!(ToolKind::Fetch.is_read_only());
209 // Fail-safe: Other is NOT read-only.
210 assert!(!ToolKind::Other.is_read_only());
211 assert!(!ToolKind::Edit.is_read_only());
212 assert!(!ToolKind::Delete.is_read_only());
213 assert!(!ToolKind::Move.is_read_only());
214 assert!(!ToolKind::Execute.is_read_only());
215 }
216
217 #[test]
218 fn mutation_class_derived_from_kind() {
219 assert_eq!(ToolKind::Read.mutation_class(), "read_only");
220 assert_eq!(ToolKind::Search.mutation_class(), "read_only");
221 assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
222 assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
223 assert_eq!(ToolKind::Move.mutation_class(), "destructive");
224 assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
225 assert_eq!(ToolKind::Other.mutation_class(), "other");
226 }
227
228 #[test]
229 fn side_effect_level_round_trip() {
230 for level in [
231 SideEffectLevel::None,
232 SideEffectLevel::ReadOnly,
233 SideEffectLevel::WorkspaceWrite,
234 SideEffectLevel::ProcessExec,
235 SideEffectLevel::Network,
236 ] {
237 assert_eq!(SideEffectLevel::parse(level.as_str()), level);
238 let encoded = serde_json::to_string(&level).unwrap();
239 let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
240 assert_eq!(decoded, level);
241 }
242 }
243
244 #[test]
245 fn side_effect_level_rank_orders() {
246 assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
247 assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
248 assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
249 assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
250 }
251
252 #[test]
253 fn arg_schema_defaults_empty() {
254 let schema = ToolArgSchema::default();
255 assert!(schema.path_params.is_empty());
256 assert!(schema.arg_aliases.is_empty());
257 assert!(schema.required.is_empty());
258 }
259
260 #[test]
261 fn annotations_default_result_routes_empty() {
262 let annotations = ToolAnnotations::default();
263 assert!(!annotations.emits_artifacts);
264 assert!(annotations.result_readers.is_empty());
265 assert!(!annotations.inline_result);
266 }
267}