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 /// Drives the physical desktop — synthetic mouse/keyboard input and screen
103 /// capture. The most invasive local class: it can operate ANY application
104 /// (not just a sandboxed subprocess or a single network sink), inject
105 /// keystrokes that paste secrets or dismiss dialogs, and every screenshot
106 /// exfiltrates whatever is on screen to the model. It therefore sits at the
107 /// top of the ceiling ladder — a policy must opt into it explicitly, above
108 /// even network access.
109 DesktopControl,
110}
111
112impl SideEffectLevel {
113 pub const ALL: [Self; 6] = [
114 Self::None,
115 Self::ReadOnly,
116 Self::WorkspaceWrite,
117 Self::ProcessExec,
118 Self::Network,
119 Self::DesktopControl,
120 ];
121
122 /// The most-permissive side-effect level — the TOP of the ladder. This is
123 /// the single source of truth for "the outermost / most-autonomous ceiling":
124 /// the runtime's builtin ceiling and the top autonomy tier both reference it,
125 /// so adding a new most-invasive level (as `desktop_control` was added above
126 /// `network`) automatically raises every permissive bound instead of leaving
127 /// hardcoded `"network"` strings that silently cap the new level out. NEVER
128 /// hardcode a specific top level as "the max"; call this.
129 pub const MAX: Self = Self::DesktopControl;
130
131 /// Numeric rank used by the policy intersector and side-effect
132 /// ceiling check. Higher rank ⇒ more invasive.
133 pub fn rank(&self) -> usize {
134 match self {
135 Self::None => 0,
136 Self::ReadOnly => 1,
137 Self::WorkspaceWrite => 2,
138 Self::ProcessExec => 3,
139 Self::Network => 4,
140 Self::DesktopControl => 5,
141 }
142 }
143
144 /// Short string used in policy documents, bridge payloads, and
145 /// error messages. Stable wire identifier.
146 pub fn as_str(&self) -> &'static str {
147 match self {
148 Self::None => "none",
149 Self::ReadOnly => "read_only",
150 Self::WorkspaceWrite => "workspace_write",
151 Self::ProcessExec => "process_exec",
152 Self::Network => "network",
153 Self::DesktopControl => "desktop_control",
154 }
155 }
156
157 /// Rank a level given as a string, through the canonical ladder — the single
158 /// source of truth for every ceiling/effect comparison that works with the
159 /// wire strings instead of the typed enum. An unrecognized value ranks as
160 /// `None` (0): tool levels always come from [`Self::as_str`] so they are
161 /// never unknown, and for a ceiling a typo then grants nothing above `none`
162 /// rather than silently widening the ceiling.
163 pub fn rank_str(level: &str) -> usize {
164 Self::parse(level).rank()
165 }
166
167 /// Parse from the stable string used in policy documents. Unknown
168 /// values deserialize to `None` (the conservative default).
169 pub fn parse(value: &str) -> Self {
170 match value {
171 "none" => Self::None,
172 "read_only" => Self::ReadOnly,
173 "workspace_write" => Self::WorkspaceWrite,
174 "process_exec" => Self::ProcessExec,
175 "network" => Self::Network,
176 "desktop_control" => Self::DesktopControl,
177 _ => Self::None,
178 }
179 }
180}
181
182/// Argument-key pair describing one inclusive numeric dependency range inside a
183/// path-scoped mutating tool call.
184#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
185#[serde(default)]
186pub struct ToolDependencyRangeParams {
187 /// Argument key whose value is the inclusive start of a dependency range.
188 pub start: String,
189 /// Argument key whose value is the inclusive end of a dependency range.
190 pub end: String,
191}
192
193/// Declarative description of a tool's argument shape. The VM uses
194/// this to:
195///
196/// - resolve `ToolArgConstraint` lookups (`path_params`),
197/// - identify independent mutation targets inside the same resource
198/// (`dependency_key_params`, `dependency_range_params`),
199/// - rewrite high-level aliases to canonical keys without any
200/// per-tool hardcoded branches (`arg_aliases`),
201/// - validate presence of required arguments at the dispatch boundary
202/// (`required`).
203#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
204#[serde(default)]
205pub struct ToolArgSchema {
206 /// Argument keys whose values are workspace-relative paths.
207 /// First matching key whose value is a string wins.
208 pub path_params: Vec<String>,
209 /// Argument keys that refine a mutating call's dependency target inside
210 /// the declared path. Schedulers use these keys to distinguish independent
211 /// same-resource writes without hardcoding tool-specific argument names.
212 pub dependency_key_params: Vec<String>,
213 /// Argument key pairs that declare an inclusive numeric dependency range
214 /// inside the declared path. Schedulers use these to detect overlapping
215 /// same-resource writes instead of relying on exact component equality.
216 pub dependency_range_params: Vec<ToolDependencyRangeParams>,
217 /// Alias → canonical key. When a tool call arrives with an alias
218 /// in its argument object, the VM rewrites the key to the canonical
219 /// form before dispatch (generic; no tool-name branches).
220 pub arg_aliases: BTreeMap<String, String>,
221 /// Argument keys that must be present (non-null) on every call.
222 pub required: Vec<String>,
223}
224
225/// Full annotations for one tool. Pipelines populate one of these per
226/// tool in the capability-policy registry; the VM consults the registry
227/// on every tool call.
228#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
229#[serde(default)]
230pub struct ToolAnnotations {
231 /// ACP-aligned tool-kind classification.
232 pub kind: ToolKind,
233 /// Required side-effect level for the capability ceiling check.
234 pub side_effect_level: SideEffectLevel,
235 /// Argument shape declarations.
236 pub arg_schema: ToolArgSchema,
237 /// Capability operations requested by this tool (e.g.
238 /// `"workspace": ["read_text", "list"]`).
239 pub capabilities: BTreeMap<String, Vec<String>>,
240 /// True when the tool may return only a handle/reference to a large
241 /// output artifact instead of inline output. Execute tools with this
242 /// flag must also declare an inspection route.
243 pub emits_artifacts: bool,
244 /// Tool names that can inspect artifacts/results emitted by this tool.
245 pub result_readers: Vec<String>,
246 /// Explicit escape hatch for tools whose results are always complete
247 /// inline, even though they are execute-like.
248 pub inline_result: bool,
249 /// MCP `readOnlyHint`. This remains advisory; policy decides whether
250 /// the server that supplied it is trusted enough to rely on it.
251 #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
252 pub read_only_hint: Option<bool>,
253 /// MCP `destructiveHint`. This remains advisory; policy decides whether
254 /// the server that supplied it is trusted enough to rely on it.
255 #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
256 pub destructive_hint: Option<bool>,
257 /// MCP `idempotentHint`. This remains advisory; policy decides whether
258 /// the server that supplied it is trusted enough to rely on it.
259 #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
260 pub idempotent_hint: Option<bool>,
261 /// MCP `openWorldHint`. This remains advisory; policy decides whether
262 /// the server that supplied it is trusted enough to rely on it.
263 #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
264 pub open_world_hint: Option<bool>,
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn tool_kind_serde_roundtrip() {
273 for (kind, expected) in [
274 (ToolKind::Read, "\"read\""),
275 (ToolKind::Edit, "\"edit\""),
276 (ToolKind::Delete, "\"delete\""),
277 (ToolKind::Move, "\"move\""),
278 (ToolKind::Search, "\"search\""),
279 (ToolKind::Execute, "\"execute\""),
280 (ToolKind::Think, "\"think\""),
281 (ToolKind::Fetch, "\"fetch\""),
282 (ToolKind::Other, "\"other\""),
283 ] {
284 let encoded = serde_json::to_string(&kind).unwrap();
285 assert_eq!(encoded, expected);
286 let decoded: ToolKind = serde_json::from_str(expected).unwrap();
287 assert_eq!(decoded, kind);
288 }
289 }
290
291 #[test]
292 fn only_read_search_think_fetch_are_read_only() {
293 assert!(ToolKind::Read.is_read_only());
294 assert!(ToolKind::Search.is_read_only());
295 assert!(ToolKind::Think.is_read_only());
296 assert!(ToolKind::Fetch.is_read_only());
297 // Fail-safe: Other is NOT read-only.
298 assert!(!ToolKind::Other.is_read_only());
299 assert!(!ToolKind::Edit.is_read_only());
300 assert!(!ToolKind::Delete.is_read_only());
301 assert!(!ToolKind::Move.is_read_only());
302 assert!(!ToolKind::Execute.is_read_only());
303 }
304
305 #[test]
306 fn mutation_class_derived_from_kind() {
307 assert_eq!(ToolKind::Read.mutation_class(), "read_only");
308 assert_eq!(ToolKind::Search.mutation_class(), "read_only");
309 assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
310 assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
311 assert_eq!(ToolKind::Move.mutation_class(), "destructive");
312 assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
313 assert_eq!(ToolKind::Other.mutation_class(), "other");
314 }
315
316 #[test]
317 fn side_effect_level_round_trip() {
318 for level in [
319 SideEffectLevel::None,
320 SideEffectLevel::ReadOnly,
321 SideEffectLevel::WorkspaceWrite,
322 SideEffectLevel::ProcessExec,
323 SideEffectLevel::Network,
324 ] {
325 assert_eq!(SideEffectLevel::parse(level.as_str()), level);
326 let encoded = serde_json::to_string(&level).unwrap();
327 let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
328 assert_eq!(decoded, level);
329 }
330 }
331
332 #[test]
333 fn side_effect_level_rank_orders() {
334 assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
335 assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
336 assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
337 assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
338 // Desktop control is the most invasive local class — top of the ladder,
339 // above even network egress.
340 assert!(SideEffectLevel::Network.rank() < SideEffectLevel::DesktopControl.rank());
341 assert_eq!(
342 SideEffectLevel::parse("desktop_control"),
343 SideEffectLevel::DesktopControl
344 );
345 assert_eq!(SideEffectLevel::DesktopControl.as_str(), "desktop_control");
346 }
347
348 #[test]
349 fn max_is_the_unique_top_of_the_ladder() {
350 // Guardrail: `SideEffectLevel::MAX` MUST be the strictly-highest-ranked
351 // level. Adding a new most-invasive variant without updating `MAX` (the
352 // single "most-permissive ceiling" the builtin ceiling and top autonomy
353 // tier both reference) fails here — so the "network was the top" footgun
354 // that silently capped `desktop_control` cannot recur.
355 for level in SideEffectLevel::ALL {
356 assert!(
357 level.rank() <= SideEffectLevel::MAX.rank(),
358 "{level:?} outranks MAX ({:?}); update SideEffectLevel::MAX",
359 SideEffectLevel::MAX
360 );
361 }
362 // And MAX is uniquely the top (exactly one level at the max rank).
363 let at_top = SideEffectLevel::ALL
364 .iter()
365 .filter(|l| l.rank() == SideEffectLevel::MAX.rank())
366 .count();
367 assert_eq!(at_top, 1, "MAX must be the unique top of the ladder");
368
369 // Compiler guardrail on `ALL` completeness: this match is exhaustive
370 // over the TYPE, so adding a variant fails the build here — and the
371 // count assertion then forces that variant into `ALL`. Without both,
372 // a variant omitted from the (hand-maintained) `ALL` array would
373 // silently escape the uniqueness check above.
374 fn _every_variant_accounted_for(level: SideEffectLevel) {
375 match level {
376 SideEffectLevel::None
377 | SideEffectLevel::ReadOnly
378 | SideEffectLevel::WorkspaceWrite
379 | SideEffectLevel::ProcessExec
380 | SideEffectLevel::Network
381 | SideEffectLevel::DesktopControl => {}
382 }
383 }
384 assert_eq!(
385 SideEffectLevel::ALL.len(),
386 6,
387 "a SideEffectLevel variant was added; list it in ALL and bump this count"
388 );
389 }
390
391 #[test]
392 fn arg_schema_defaults_empty() {
393 let schema = ToolArgSchema::default();
394 assert!(schema.path_params.is_empty());
395 assert!(schema.dependency_key_params.is_empty());
396 assert!(schema.dependency_range_params.is_empty());
397 assert!(schema.arg_aliases.is_empty());
398 assert!(schema.required.is_empty());
399 }
400
401 #[test]
402 fn annotations_default_result_routes_empty() {
403 let annotations = ToolAnnotations::default();
404 assert!(!annotations.emits_artifacts);
405 assert!(annotations.result_readers.is_empty());
406 assert!(!annotations.inline_result);
407 }
408
409 #[test]
410 fn mcp_annotation_hints_round_trip() {
411 let annotations: ToolAnnotations = serde_json::from_value(serde_json::json!({
412 "readOnlyHint": true,
413 "destructiveHint": false,
414 "idempotentHint": true,
415 "openWorldHint": false
416 }))
417 .expect("MCP hints should deserialize");
418 assert_eq!(annotations.read_only_hint, Some(true));
419 assert_eq!(annotations.destructive_hint, Some(false));
420 assert_eq!(annotations.idempotent_hint, Some(true));
421 assert_eq!(annotations.open_world_hint, Some(false));
422
423 let encoded = serde_json::to_value(&annotations).expect("serialize annotations");
424 assert_eq!(encoded["readOnlyHint"], true);
425 assert_eq!(encoded["idempotentHint"], true);
426 }
427}