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    /// 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/// How a tool's completed result participates in a bounded completion check.
226/// This is orthogonal to ACP `ToolKind`: two execute tools can have different
227/// completion roles (for example, a verifier versus a release mutation).
228#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum CompletionEvidenceRole {
231    Observation,
232    Mutation,
233    Verification,
234}
235
236impl CompletionEvidenceRole {
237    /// Exhaustive wire vocabulary used by generated host bindings.
238    pub const ALL: [Self; 3] = [Self::Observation, Self::Mutation, Self::Verification];
239}
240
241/// Full annotations for one tool. Pipelines populate one of these per
242/// tool in the capability-policy registry; the VM consults the registry
243/// on every tool call.
244#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
245#[serde(default)]
246pub struct ToolAnnotations {
247    /// ACP-aligned tool-kind classification.
248    pub kind: ToolKind,
249    /// Required side-effect level for the capability ceiling check.
250    pub side_effect_level: SideEffectLevel,
251    /// Explicit completion-evidence role. Absent means the tool does not claim
252    /// a specialized role; consumers may still recognize canonical read/edit
253    /// semantics, but must not guess that an arbitrary execute tool verifies.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub completion_evidence_role: Option<CompletionEvidenceRole>,
256    /// Argument shape declarations.
257    pub arg_schema: ToolArgSchema,
258    /// Capability operations requested by this tool (e.g.
259    /// `"workspace": ["read_text", "list"]`).
260    pub capabilities: BTreeMap<String, Vec<String>>,
261    /// True when the tool may return only a handle/reference to a large
262    /// output artifact instead of inline output. Execute tools with this
263    /// flag must also declare an inspection route.
264    pub emits_artifacts: bool,
265    /// Tool names that can inspect artifacts/results emitted by this tool.
266    pub result_readers: Vec<String>,
267    /// Explicit escape hatch for tools whose results are always complete
268    /// inline, even though they are execute-like.
269    pub inline_result: bool,
270    /// MCP `readOnlyHint`. This remains advisory; policy decides whether
271    /// the server that supplied it is trusted enough to rely on it.
272    #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
273    pub read_only_hint: Option<bool>,
274    /// MCP `destructiveHint`. This remains advisory; policy decides whether
275    /// the server that supplied it is trusted enough to rely on it.
276    #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
277    pub destructive_hint: Option<bool>,
278    /// MCP `idempotentHint`. This remains advisory; policy decides whether
279    /// the server that supplied it is trusted enough to rely on it.
280    #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
281    pub idempotent_hint: Option<bool>,
282    /// MCP `openWorldHint`. This remains advisory; policy decides whether
283    /// the server that supplied it is trusted enough to rely on it.
284    #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
285    pub open_world_hint: Option<bool>,
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn tool_kind_serde_roundtrip() {
294        for (kind, expected) in [
295            (ToolKind::Read, "\"read\""),
296            (ToolKind::Edit, "\"edit\""),
297            (ToolKind::Delete, "\"delete\""),
298            (ToolKind::Move, "\"move\""),
299            (ToolKind::Search, "\"search\""),
300            (ToolKind::Execute, "\"execute\""),
301            (ToolKind::Think, "\"think\""),
302            (ToolKind::Fetch, "\"fetch\""),
303            (ToolKind::Other, "\"other\""),
304        ] {
305            let encoded = serde_json::to_string(&kind).unwrap();
306            assert_eq!(encoded, expected);
307            let decoded: ToolKind = serde_json::from_str(expected).unwrap();
308            assert_eq!(decoded, kind);
309        }
310    }
311
312    #[test]
313    fn only_read_search_think_fetch_are_read_only() {
314        assert!(ToolKind::Read.is_read_only());
315        assert!(ToolKind::Search.is_read_only());
316        assert!(ToolKind::Think.is_read_only());
317        assert!(ToolKind::Fetch.is_read_only());
318        // Fail-safe: Other is NOT read-only.
319        assert!(!ToolKind::Other.is_read_only());
320        assert!(!ToolKind::Edit.is_read_only());
321        assert!(!ToolKind::Delete.is_read_only());
322        assert!(!ToolKind::Move.is_read_only());
323        assert!(!ToolKind::Execute.is_read_only());
324    }
325
326    #[test]
327    fn mutation_class_derived_from_kind() {
328        assert_eq!(ToolKind::Read.mutation_class(), "read_only");
329        assert_eq!(ToolKind::Search.mutation_class(), "read_only");
330        assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
331        assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
332        assert_eq!(ToolKind::Move.mutation_class(), "destructive");
333        assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
334        assert_eq!(ToolKind::Other.mutation_class(), "other");
335    }
336
337    #[test]
338    fn side_effect_level_round_trip() {
339        for level in [
340            SideEffectLevel::None,
341            SideEffectLevel::ReadOnly,
342            SideEffectLevel::WorkspaceWrite,
343            SideEffectLevel::ProcessExec,
344            SideEffectLevel::Network,
345        ] {
346            assert_eq!(SideEffectLevel::parse(level.as_str()), level);
347            let encoded = serde_json::to_string(&level).unwrap();
348            let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
349            assert_eq!(decoded, level);
350        }
351    }
352
353    #[test]
354    fn side_effect_level_rank_orders() {
355        assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
356        assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
357        assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
358        assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
359        // Desktop control is the most invasive local class — top of the ladder,
360        // above even network egress.
361        assert!(SideEffectLevel::Network.rank() < SideEffectLevel::DesktopControl.rank());
362        assert_eq!(
363            SideEffectLevel::parse("desktop_control"),
364            SideEffectLevel::DesktopControl
365        );
366        assert_eq!(SideEffectLevel::DesktopControl.as_str(), "desktop_control");
367    }
368
369    #[test]
370    fn max_is_the_unique_top_of_the_ladder() {
371        // Guardrail: `SideEffectLevel::MAX` MUST be the strictly-highest-ranked
372        // level. Adding a new most-invasive variant without updating `MAX` (the
373        // single "most-permissive ceiling" the builtin ceiling and top autonomy
374        // tier both reference) fails here — so the "network was the top" footgun
375        // that silently capped `desktop_control` cannot recur.
376        for level in SideEffectLevel::ALL {
377            assert!(
378                level.rank() <= SideEffectLevel::MAX.rank(),
379                "{level:?} outranks MAX ({:?}); update SideEffectLevel::MAX",
380                SideEffectLevel::MAX
381            );
382        }
383        // And MAX is uniquely the top (exactly one level at the max rank).
384        let at_top = SideEffectLevel::ALL
385            .iter()
386            .filter(|l| l.rank() == SideEffectLevel::MAX.rank())
387            .count();
388        assert_eq!(at_top, 1, "MAX must be the unique top of the ladder");
389
390        // Compiler guardrail on `ALL` completeness: this match is exhaustive
391        // over the TYPE, so adding a variant fails the build here — and the
392        // count assertion then forces that variant into `ALL`. Without both,
393        // a variant omitted from the (hand-maintained) `ALL` array would
394        // silently escape the uniqueness check above.
395        fn _every_variant_accounted_for(level: SideEffectLevel) {
396            match level {
397                SideEffectLevel::None
398                | SideEffectLevel::ReadOnly
399                | SideEffectLevel::WorkspaceWrite
400                | SideEffectLevel::ProcessExec
401                | SideEffectLevel::Network
402                | SideEffectLevel::DesktopControl => {}
403            }
404        }
405        assert_eq!(
406            SideEffectLevel::ALL.len(),
407            6,
408            "a SideEffectLevel variant was added; list it in ALL and bump this count"
409        );
410    }
411
412    #[test]
413    fn arg_schema_defaults_empty() {
414        let schema = ToolArgSchema::default();
415        assert!(schema.path_params.is_empty());
416        assert!(schema.dependency_key_params.is_empty());
417        assert!(schema.dependency_range_params.is_empty());
418        assert!(schema.arg_aliases.is_empty());
419        assert!(schema.required.is_empty());
420    }
421
422    #[test]
423    fn annotations_default_result_routes_empty() {
424        let annotations = ToolAnnotations::default();
425        assert!(!annotations.emits_artifacts);
426        assert!(annotations.result_readers.is_empty());
427        assert!(!annotations.inline_result);
428        assert_eq!(annotations.completion_evidence_role, None);
429    }
430
431    #[test]
432    fn completion_evidence_roles_round_trip() {
433        for role in CompletionEvidenceRole::ALL {
434            let encoded = serde_json::to_value(role).expect("serialize evidence role");
435            let decoded: CompletionEvidenceRole =
436                serde_json::from_value(encoded).expect("deserialize evidence role");
437            assert_eq!(decoded, role);
438        }
439    }
440
441    #[test]
442    fn mcp_annotation_hints_round_trip() {
443        let annotations: ToolAnnotations = serde_json::from_value(serde_json::json!({
444            "readOnlyHint": true,
445            "destructiveHint": false,
446            "idempotentHint": true,
447            "openWorldHint": false
448        }))
449        .expect("MCP hints should deserialize");
450        assert_eq!(annotations.read_only_hint, Some(true));
451        assert_eq!(annotations.destructive_hint, Some(false));
452        assert_eq!(annotations.idempotent_hint, Some(true));
453        assert_eq!(annotations.open_world_hint, Some(false));
454
455        let encoded = serde_json::to_value(&annotations).expect("serialize annotations");
456        assert_eq!(encoded["readOnlyHint"], true);
457        assert_eq!(encoded["idempotentHint"], true);
458    }
459}