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    /// 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}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn tool_kind_serde_roundtrip() {
176        for (kind, expected) in [
177            (ToolKind::Read, "\"read\""),
178            (ToolKind::Edit, "\"edit\""),
179            (ToolKind::Delete, "\"delete\""),
180            (ToolKind::Move, "\"move\""),
181            (ToolKind::Search, "\"search\""),
182            (ToolKind::Execute, "\"execute\""),
183            (ToolKind::Think, "\"think\""),
184            (ToolKind::Fetch, "\"fetch\""),
185            (ToolKind::Other, "\"other\""),
186        ] {
187            let encoded = serde_json::to_string(&kind).unwrap();
188            assert_eq!(encoded, expected);
189            let decoded: ToolKind = serde_json::from_str(expected).unwrap();
190            assert_eq!(decoded, kind);
191        }
192    }
193
194    #[test]
195    fn only_read_search_think_fetch_are_read_only() {
196        assert!(ToolKind::Read.is_read_only());
197        assert!(ToolKind::Search.is_read_only());
198        assert!(ToolKind::Think.is_read_only());
199        assert!(ToolKind::Fetch.is_read_only());
200        // Fail-safe: Other is NOT read-only.
201        assert!(!ToolKind::Other.is_read_only());
202        assert!(!ToolKind::Edit.is_read_only());
203        assert!(!ToolKind::Delete.is_read_only());
204        assert!(!ToolKind::Move.is_read_only());
205        assert!(!ToolKind::Execute.is_read_only());
206    }
207
208    #[test]
209    fn mutation_class_derived_from_kind() {
210        assert_eq!(ToolKind::Read.mutation_class(), "read_only");
211        assert_eq!(ToolKind::Search.mutation_class(), "read_only");
212        assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
213        assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
214        assert_eq!(ToolKind::Move.mutation_class(), "destructive");
215        assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
216        assert_eq!(ToolKind::Other.mutation_class(), "other");
217    }
218
219    #[test]
220    fn side_effect_level_round_trip() {
221        for level in [
222            SideEffectLevel::None,
223            SideEffectLevel::ReadOnly,
224            SideEffectLevel::WorkspaceWrite,
225            SideEffectLevel::ProcessExec,
226            SideEffectLevel::Network,
227        ] {
228            assert_eq!(SideEffectLevel::parse(level.as_str()), level);
229            let encoded = serde_json::to_string(&level).unwrap();
230            let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
231            assert_eq!(decoded, level);
232        }
233    }
234
235    #[test]
236    fn side_effect_level_rank_orders() {
237        assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
238        assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
239        assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
240        assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
241    }
242
243    #[test]
244    fn arg_schema_defaults_empty() {
245        let schema = ToolArgSchema::default();
246        assert!(schema.path_params.is_empty());
247        assert!(schema.arg_aliases.is_empty());
248        assert!(schema.required.is_empty());
249    }
250}