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 schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use ts_rs::TS;
20
21/// Canonical tool-kind vocabulary. Matches the ACP `ToolKind` enum so
22/// harn-cli's ACP server can forward the value unchanged in
23/// `sessionUpdate` variants.
24///
25/// The VM treats `Read`, `Search`, `Think`, and `Fetch` as read-only
26/// for concurrent-dispatch purposes. `Other` is intentionally NOT
27/// treated as read-only — unannotated tools should not slip through
28/// as auto-approved by default (fail-safe).
29#[derive(
30 Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize, JsonSchema, TS,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum ToolKind {
34 /// Reads file/workspace content without mutation.
35 Read,
36 /// Mutates workspace content (write, patch, edit).
37 Edit,
38 /// Removes content irreversibly.
39 Delete,
40 /// Relocates or renames content.
41 Move,
42 /// Queries indexes or directories; no mutation.
43 Search,
44 /// Runs a subprocess or a shell command.
45 Execute,
46 /// Pure reasoning/thought invocation, no side effects.
47 Think,
48 /// Retrieves remote content (HTTP, MCP fetch, etc.).
49 Fetch,
50 /// Anything that doesn't map cleanly into the canonical kinds.
51 /// Not treated as read-only — the fail-safe default.
52 #[default]
53 Other,
54}
55
56impl ToolKind {
57 pub const ALL: [Self; 9] = [
58 Self::Read,
59 Self::Edit,
60 Self::Delete,
61 Self::Move,
62 Self::Search,
63 Self::Execute,
64 Self::Think,
65 Self::Fetch,
66 Self::Other,
67 ];
68
69 /// Read-only tools can dispatch concurrently without risking
70 /// conflicting state mutations. `Other` is excluded by design —
71 /// unannotated tools must not auto-approve as read-only.
72 pub fn is_read_only(&self) -> bool {
73 matches!(self, Self::Read | Self::Search | Self::Think | Self::Fetch)
74 }
75
76 /// Coarse mutation-classification string used in tool-call
77 /// telemetry and pre/post bridge payloads. Derived directly from
78 /// the kind — the VM no longer guesses from tool names.
79 pub fn mutation_class(&self) -> &'static str {
80 match self {
81 Self::Read | Self::Search | Self::Think | Self::Fetch => "read_only",
82 Self::Edit => "workspace_write",
83 Self::Delete | Self::Move => "destructive",
84 Self::Execute => "ambient_side_effect",
85 Self::Other => "other",
86 }
87 }
88}
89
90/// Rough side-effect taxonomy for the capability-ceiling check.
91#[derive(
92 Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize, JsonSchema, TS,
93)]
94#[serde(rename_all = "snake_case")]
95pub enum SideEffectLevel {
96 /// No side effect declared (conservative default; permission logic
97 /// treats this as "unknown → deny unless explicitly allowed").
98 #[default]
99 None,
100 /// Pure reads only.
101 ReadOnly,
102 /// Writes to workspace files.
103 WorkspaceWrite,
104 /// Runs subprocesses.
105 ProcessExec,
106 /// Reaches external services over the network.
107 Network,
108 /// Drives the physical desktop — synthetic mouse/keyboard input and screen
109 /// capture. The most invasive local class: it can operate ANY application
110 /// (not just a sandboxed subprocess or a single network sink), inject
111 /// keystrokes that paste secrets or dismiss dialogs, and every screenshot
112 /// exfiltrates whatever is on screen to the model. It therefore sits at the
113 /// top of the ceiling ladder — a policy must opt into it explicitly, above
114 /// even network access.
115 DesktopControl,
116}
117
118impl SideEffectLevel {
119 pub const ALL: [Self; 6] = [
120 Self::None,
121 Self::ReadOnly,
122 Self::WorkspaceWrite,
123 Self::ProcessExec,
124 Self::Network,
125 Self::DesktopControl,
126 ];
127
128 /// The most-permissive side-effect level — the TOP of the ladder. This is
129 /// the single source of truth for "the outermost / most-autonomous ceiling":
130 /// the runtime's builtin ceiling and the top autonomy tier both reference it,
131 /// so adding a new most-invasive level (as `desktop_control` was added above
132 /// `network`) automatically raises every permissive bound instead of leaving
133 /// hardcoded `"network"` strings that silently cap the new level out. NEVER
134 /// hardcode a specific top level as "the max"; call this.
135 pub const MAX: Self = Self::DesktopControl;
136
137 /// Numeric rank used by the policy intersector and side-effect
138 /// ceiling check. Higher rank ⇒ more invasive.
139 pub fn rank(&self) -> usize {
140 match self {
141 Self::None => 0,
142 Self::ReadOnly => 1,
143 Self::WorkspaceWrite => 2,
144 Self::ProcessExec => 3,
145 Self::Network => 4,
146 Self::DesktopControl => 5,
147 }
148 }
149
150 /// Short string used in policy documents, bridge payloads, and
151 /// error messages. Stable wire identifier.
152 pub fn as_str(&self) -> &'static str {
153 match self {
154 Self::None => "none",
155 Self::ReadOnly => "read_only",
156 Self::WorkspaceWrite => "workspace_write",
157 Self::ProcessExec => "process_exec",
158 Self::Network => "network",
159 Self::DesktopControl => "desktop_control",
160 }
161 }
162
163 /// Rank a level given as a string, through the canonical ladder — the single
164 /// source of truth for every ceiling/effect comparison that works with the
165 /// wire strings instead of the typed enum. An unrecognized value ranks as
166 /// `None` (0): tool levels always come from [`Self::as_str`] so they are
167 /// never unknown, and for a ceiling a typo then grants nothing above `none`
168 /// rather than silently widening the ceiling.
169 pub fn rank_str(level: &str) -> usize {
170 Self::parse(level).rank()
171 }
172
173 /// Parse from the stable string used in policy documents. Unknown
174 /// values deserialize to `None` (the conservative default).
175 pub fn parse(value: &str) -> Self {
176 match value {
177 "none" => Self::None,
178 "read_only" => Self::ReadOnly,
179 "workspace_write" => Self::WorkspaceWrite,
180 "process_exec" => Self::ProcessExec,
181 "network" => Self::Network,
182 "desktop_control" => Self::DesktopControl,
183 _ => Self::None,
184 }
185 }
186}
187
188/// Argument-key pair describing one inclusive numeric dependency range inside a
189/// path-scoped mutating tool call.
190#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
191#[serde(default)]
192pub struct ToolDependencyRangeParams {
193 /// Argument key whose value is the inclusive start of a dependency range.
194 pub start: String,
195 /// Argument key whose value is the inclusive end of a dependency range.
196 pub end: String,
197}
198
199/// Declarative description of a tool's argument shape. The VM uses
200/// this to:
201///
202/// - resolve `ToolArgConstraint` lookups (`path_params`),
203/// - identify independent mutation targets inside the same resource
204/// (`dependency_key_params`, `dependency_range_params`),
205/// - rewrite high-level aliases to canonical keys without any
206/// per-tool hardcoded branches (`arg_aliases`),
207/// - validate presence of required arguments at the dispatch boundary
208/// (`required`).
209#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
210#[serde(default)]
211pub struct ToolArgSchema {
212 /// Argument keys whose values are workspace-relative paths.
213 /// First matching key whose value is a string wins.
214 pub path_params: Vec<String>,
215 /// Argument keys that refine a mutating call's dependency target inside
216 /// the declared path. Schedulers use these keys to distinguish independent
217 /// same-resource writes without hardcoding tool-specific argument names.
218 pub dependency_key_params: Vec<String>,
219 /// Argument key pairs that declare an inclusive numeric dependency range
220 /// inside the declared path. Schedulers use these to detect overlapping
221 /// same-resource writes instead of relying on exact component equality.
222 pub dependency_range_params: Vec<ToolDependencyRangeParams>,
223 /// Alias → canonical key. When a tool call arrives with an alias
224 /// in its argument object, the VM rewrites the key to the canonical
225 /// form before dispatch (generic; no tool-name branches).
226 pub arg_aliases: BTreeMap<String, String>,
227 /// Argument keys that must be present (non-null) on every call.
228 pub required: Vec<String>,
229}
230
231/// How a tool's completed result participates in a bounded completion check.
232/// This is orthogonal to ACP `ToolKind`: two execute tools can have different
233/// completion roles (for example, a verifier versus a release mutation).
234#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum CompletionEvidenceRole {
237 Observation,
238 Mutation,
239 Verification,
240}
241
242impl CompletionEvidenceRole {
243 /// Exhaustive wire vocabulary used by generated host bindings.
244 pub const ALL: [Self; 3] = [Self::Observation, Self::Mutation, Self::Verification];
245}
246
247/// Full annotations for one tool. Pipelines populate one of these per
248/// tool in the capability-policy registry; the VM consults the registry
249/// on every tool call.
250#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
251#[serde(default)]
252pub struct ToolAnnotations {
253 /// ACP-aligned tool-kind classification.
254 pub kind: ToolKind,
255 /// Required side-effect level for the capability ceiling check.
256 pub side_effect_level: SideEffectLevel,
257 /// Explicit completion-evidence role. Absent means the tool does not claim
258 /// a specialized role; consumers may still recognize canonical read/edit
259 /// semantics, but must not guess that an arbitrary execute tool verifies.
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub completion_evidence_role: Option<CompletionEvidenceRole>,
262 /// Argument shape declarations.
263 pub arg_schema: ToolArgSchema,
264 /// Capability operations requested by this tool (e.g.
265 /// `"workspace": ["read_text", "list"]`).
266 pub capabilities: BTreeMap<String, Vec<String>>,
267 /// True when the tool may return only a handle/reference to a large
268 /// output artifact instead of inline output. Execute tools with this
269 /// flag must also declare an inspection route.
270 pub emits_artifacts: bool,
271 /// Tool names that can inspect artifacts/results emitted by this tool.
272 pub result_readers: Vec<String>,
273 /// Explicit escape hatch for tools whose results are always complete
274 /// inline, even though they are execute-like.
275 pub inline_result: bool,
276 /// MCP `readOnlyHint`. This remains advisory; policy decides whether
277 /// the server that supplied it is trusted enough to rely on it.
278 #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
279 pub read_only_hint: Option<bool>,
280 /// MCP `destructiveHint`. This remains advisory; policy decides whether
281 /// the server that supplied it is trusted enough to rely on it.
282 #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
283 pub destructive_hint: Option<bool>,
284 /// MCP `idempotentHint`. This remains advisory; policy decides whether
285 /// the server that supplied it is trusted enough to rely on it.
286 #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
287 pub idempotent_hint: Option<bool>,
288 /// MCP `openWorldHint`. This remains advisory; policy decides whether
289 /// the server that supplied it is trusted enough to rely on it.
290 #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
291 pub open_world_hint: Option<bool>,
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn tool_kind_serde_roundtrip() {
300 for (kind, expected) in [
301 (ToolKind::Read, "\"read\""),
302 (ToolKind::Edit, "\"edit\""),
303 (ToolKind::Delete, "\"delete\""),
304 (ToolKind::Move, "\"move\""),
305 (ToolKind::Search, "\"search\""),
306 (ToolKind::Execute, "\"execute\""),
307 (ToolKind::Think, "\"think\""),
308 (ToolKind::Fetch, "\"fetch\""),
309 (ToolKind::Other, "\"other\""),
310 ] {
311 let encoded = serde_json::to_string(&kind).unwrap();
312 assert_eq!(encoded, expected);
313 let decoded: ToolKind = serde_json::from_str(expected).unwrap();
314 assert_eq!(decoded, kind);
315 }
316 }
317
318 #[test]
319 fn only_read_search_think_fetch_are_read_only() {
320 assert!(ToolKind::Read.is_read_only());
321 assert!(ToolKind::Search.is_read_only());
322 assert!(ToolKind::Think.is_read_only());
323 assert!(ToolKind::Fetch.is_read_only());
324 // Fail-safe: Other is NOT read-only.
325 assert!(!ToolKind::Other.is_read_only());
326 assert!(!ToolKind::Edit.is_read_only());
327 assert!(!ToolKind::Delete.is_read_only());
328 assert!(!ToolKind::Move.is_read_only());
329 assert!(!ToolKind::Execute.is_read_only());
330 }
331
332 #[test]
333 fn mutation_class_derived_from_kind() {
334 assert_eq!(ToolKind::Read.mutation_class(), "read_only");
335 assert_eq!(ToolKind::Search.mutation_class(), "read_only");
336 assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
337 assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
338 assert_eq!(ToolKind::Move.mutation_class(), "destructive");
339 assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
340 assert_eq!(ToolKind::Other.mutation_class(), "other");
341 }
342
343 #[test]
344 fn side_effect_level_round_trip() {
345 for level in [
346 SideEffectLevel::None,
347 SideEffectLevel::ReadOnly,
348 SideEffectLevel::WorkspaceWrite,
349 SideEffectLevel::ProcessExec,
350 SideEffectLevel::Network,
351 ] {
352 assert_eq!(SideEffectLevel::parse(level.as_str()), level);
353 let encoded = serde_json::to_string(&level).unwrap();
354 let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
355 assert_eq!(decoded, level);
356 }
357 }
358
359 #[test]
360 fn side_effect_level_rank_orders() {
361 assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
362 assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
363 assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
364 assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
365 // Desktop control is the most invasive local class — top of the ladder,
366 // above even network egress.
367 assert!(SideEffectLevel::Network.rank() < SideEffectLevel::DesktopControl.rank());
368 assert_eq!(
369 SideEffectLevel::parse("desktop_control"),
370 SideEffectLevel::DesktopControl
371 );
372 assert_eq!(SideEffectLevel::DesktopControl.as_str(), "desktop_control");
373 }
374
375 #[test]
376 fn max_is_the_unique_top_of_the_ladder() {
377 // Guardrail: `SideEffectLevel::MAX` MUST be the strictly-highest-ranked
378 // level. Adding a new most-invasive variant without updating `MAX` (the
379 // single "most-permissive ceiling" the builtin ceiling and top autonomy
380 // tier both reference) fails here — so the "network was the top" footgun
381 // that silently capped `desktop_control` cannot recur.
382 for level in SideEffectLevel::ALL {
383 assert!(
384 level.rank() <= SideEffectLevel::MAX.rank(),
385 "{level:?} outranks MAX ({:?}); update SideEffectLevel::MAX",
386 SideEffectLevel::MAX
387 );
388 }
389 // And MAX is uniquely the top (exactly one level at the max rank).
390 let at_top = SideEffectLevel::ALL
391 .iter()
392 .filter(|l| l.rank() == SideEffectLevel::MAX.rank())
393 .count();
394 assert_eq!(at_top, 1, "MAX must be the unique top of the ladder");
395
396 // Compiler guardrail on `ALL` completeness: this match is exhaustive
397 // over the TYPE, so adding a variant fails the build here — and the
398 // count assertion then forces that variant into `ALL`. Without both,
399 // a variant omitted from the (hand-maintained) `ALL` array would
400 // silently escape the uniqueness check above.
401 fn _every_variant_accounted_for(level: SideEffectLevel) {
402 match level {
403 SideEffectLevel::None
404 | SideEffectLevel::ReadOnly
405 | SideEffectLevel::WorkspaceWrite
406 | SideEffectLevel::ProcessExec
407 | SideEffectLevel::Network
408 | SideEffectLevel::DesktopControl => {}
409 }
410 }
411 assert_eq!(
412 SideEffectLevel::ALL.len(),
413 6,
414 "a SideEffectLevel variant was added; list it in ALL and bump this count"
415 );
416 }
417
418 #[test]
419 fn arg_schema_defaults_empty() {
420 let schema = ToolArgSchema::default();
421 assert!(schema.path_params.is_empty());
422 assert!(schema.dependency_key_params.is_empty());
423 assert!(schema.dependency_range_params.is_empty());
424 assert!(schema.arg_aliases.is_empty());
425 assert!(schema.required.is_empty());
426 }
427
428 #[test]
429 fn annotations_default_result_routes_empty() {
430 let annotations = ToolAnnotations::default();
431 assert!(!annotations.emits_artifacts);
432 assert!(annotations.result_readers.is_empty());
433 assert!(!annotations.inline_result);
434 assert_eq!(annotations.completion_evidence_role, None);
435 }
436
437 #[test]
438 fn completion_evidence_roles_round_trip() {
439 for role in CompletionEvidenceRole::ALL {
440 let encoded = serde_json::to_value(role).expect("serialize evidence role");
441 let decoded: CompletionEvidenceRole =
442 serde_json::from_value(encoded).expect("deserialize evidence role");
443 assert_eq!(decoded, role);
444 }
445 }
446
447 #[test]
448 fn mcp_annotation_hints_round_trip() {
449 let annotations: ToolAnnotations = serde_json::from_value(serde_json::json!({
450 "readOnlyHint": true,
451 "destructiveHint": false,
452 "idempotentHint": true,
453 "openWorldHint": false
454 }))
455 .expect("MCP hints should deserialize");
456 assert_eq!(annotations.read_only_hint, Some(true));
457 assert_eq!(annotations.destructive_hint, Some(false));
458 assert_eq!(annotations.idempotent_hint, Some(true));
459 assert_eq!(annotations.open_world_hint, Some(false));
460
461 let encoded = serde_json::to_value(&annotations).expect("serialize annotations");
462 assert_eq!(encoded["readOnlyHint"], true);
463 assert_eq!(encoded["idempotentHint"], true);
464 }
465}