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 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}