1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
8
9pub const BINDING_MANIFEST_SCHEMA_VERSION: u32 = 1;
10
11#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BindingPolicyDisposition {
15 Allowed,
16 Gated,
17 Denied,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct BindingPolicyStatus {
24 pub disposition: BindingPolicyDisposition,
25 pub reason: Option<String>,
26}
27
28impl Default for BindingPolicyStatus {
29 fn default() -> Self {
30 Self {
31 disposition: BindingPolicyDisposition::Allowed,
32 reason: None,
33 }
34 }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
39#[serde(default)]
40pub struct BindingManifestEntry {
41 pub name: String,
43 pub binding: String,
45 pub namespace: Option<String>,
46 pub description: Option<String>,
47 pub input_schema: Value,
48 pub output_schema: Option<Value>,
49 pub annotations: ToolAnnotations,
50 pub side_effect_level: SideEffectLevel,
51 pub capabilities: BTreeMap<String, Vec<String>>,
52 pub path_args: Vec<String>,
53 pub examples: Vec<Value>,
54 pub source: String,
57 pub deferred: bool,
58 pub policy: BindingPolicyStatus,
59 pub metadata: Value,
60}
61
62impl Default for BindingManifestEntry {
63 fn default() -> Self {
64 Self {
65 name: String::new(),
66 binding: String::new(),
67 namespace: None,
68 description: None,
69 input_schema: serde_json::json!({"type": "object"}),
70 output_schema: None,
71 annotations: ToolAnnotations::default(),
72 side_effect_level: SideEffectLevel::None,
73 capabilities: BTreeMap::new(),
74 path_args: Vec::new(),
75 examples: Vec::new(),
76 source: "harn".to_string(),
77 deferred: false,
78 policy: BindingPolicyStatus::default(),
79 metadata: Value::Object(serde_json::Map::new()),
80 }
81 }
82}
83
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
86#[serde(default)]
87pub struct BindingManifest {
88 pub schema_version: u32,
89 pub bindings: Vec<BindingManifestEntry>,
90 pub side_effect_ceiling: SideEffectLevel,
91 pub metadata: Value,
92}
93
94impl Default for BindingManifest {
95 fn default() -> Self {
96 Self {
97 schema_version: BINDING_MANIFEST_SCHEMA_VERSION,
98 bindings: Vec::new(),
99 side_effect_ceiling: SideEffectLevel::ReadOnly,
100 metadata: Value::Object(serde_json::Map::new()),
101 }
102 }
103}
104
105impl BindingManifest {
106 pub fn new(mut bindings: Vec<BindingManifestEntry>, ceiling: SideEffectLevel) -> Self {
107 bindings.sort_by(|a, b| a.binding.cmp(&b.binding).then(a.name.cmp(&b.name)));
108 Self {
109 bindings,
110 side_effect_ceiling: ceiling,
111 ..Self::default()
112 }
113 }
114
115 pub fn to_value(&self) -> Value {
116 serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({"bindings": []}))
117 }
118
119 pub fn to_compact_value(&self) -> Value {
120 Value::Object(serde_json::Map::from_iter([
121 (
122 "schema_version".to_string(),
123 Value::Number(self.schema_version.into()),
124 ),
125 (
126 "side_effect_ceiling".to_string(),
127 serde_json::json!(self.side_effect_ceiling),
128 ),
129 (
130 "bindings".to_string(),
131 Value::Array(
132 self.bindings
133 .iter()
134 .map(|binding| {
135 serde_json::json!({
136 "name": binding.name,
137 "binding": binding.binding,
138 "namespace": binding.namespace,
139 "description": binding.description,
140 "side_effect_level": binding.side_effect_level,
141 "policy": binding.policy,
142 "source": binding.source,
143 "deferred": binding.deferred,
144 "examples": binding.examples,
145 })
146 })
147 .collect(),
148 ),
149 ),
150 ]))
151 }
152
153 pub fn hash(&self) -> Result<String, serde_json::Error> {
154 binding_manifest_hash(&self.to_value())
155 }
156
157 pub fn find_by_binding(&self, binding: &str) -> Option<&BindingManifestEntry> {
158 self.bindings.iter().find(|entry| entry.binding == binding)
159 }
160
161 pub fn find_by_name(&self, name: &str) -> Option<&BindingManifestEntry> {
162 self.bindings.iter().find(|entry| entry.name == name)
163 }
164}
165
166pub fn binding_manifest_hash(manifest: &Value) -> Result<String, serde_json::Error> {
169 let canonical = serde_json::to_vec(manifest)?;
170 let mut hasher = Sha256::new();
171 hasher.update(b"harn.composition.binding_manifest.v1\0");
172 hasher.update(&canonical);
173 Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
174}
175
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct BindingManifestOptions {
178 pub side_effect_ceiling: SideEffectLevel,
179 pub include_denied: bool,
180 pub denied_tools: BTreeSet<String>,
181 pub gated_tools: BTreeSet<String>,
182}
183
184impl Default for BindingManifestOptions {
185 fn default() -> Self {
186 Self {
187 side_effect_ceiling: SideEffectLevel::ReadOnly,
188 include_denied: false,
189 denied_tools: BTreeSet::new(),
190 gated_tools: BTreeSet::new(),
191 }
192 }
193}
194
195pub fn binding_manifest_from_tool_surface(
198 tools: &Value,
199 options: BindingManifestOptions,
200) -> BindingManifest {
201 let mut used_bindings = BTreeSet::new();
202 let annotations_by_name = crate::tool_surface::tool_annotations_from_spec(tools);
203 let mut entries = Vec::new();
204 for tool in tool_surface_entries(tools) {
205 let Some(name) = tool
206 .get("name")
207 .and_then(Value::as_str)
208 .filter(|s| !s.is_empty())
209 else {
210 continue;
211 };
212 let annotations = tool
213 .get("annotations")
214 .cloned()
215 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
216 .or_else(|| annotations_by_name.get(name).cloned())
217 .unwrap_or_default();
218 let side_effect_level = annotations.side_effect_level;
219 let mut policy = BindingPolicyStatus::default();
220 if options.denied_tools.contains(name) {
221 policy.disposition = BindingPolicyDisposition::Denied;
222 policy.reason = Some("denied by active tool policy".to_string());
223 } else if side_effect_level.rank() > options.side_effect_ceiling.rank() {
224 policy.disposition = BindingPolicyDisposition::Denied;
225 policy.reason = Some(format!(
226 "requires side-effect level '{}' above composition ceiling '{}'",
227 side_effect_level.as_str(),
228 options.side_effect_ceiling.as_str()
229 ));
230 } else if options.gated_tools.contains(name) {
231 policy.disposition = BindingPolicyDisposition::Gated;
232 policy.reason = Some("requires host approval before dispatch".to_string());
233 }
234 if !options.include_denied && policy.disposition == BindingPolicyDisposition::Denied {
235 continue;
236 }
237 let binding = unique_binding_identifier(name, &mut used_bindings);
238 let source = binding_source(&tool);
239 let deferred = tool
240 .get("defer_loading")
241 .and_then(Value::as_bool)
242 .or_else(|| {
243 tool.get("function")
244 .and_then(|function| function.get("defer_loading"))
245 .and_then(Value::as_bool)
246 })
247 .unwrap_or(source == "deferred");
248 let input_schema = tool
249 .get("inputSchema")
250 .or_else(|| tool.get("input_schema"))
251 .or_else(|| tool.get("parameters"))
252 .or_else(|| tool.get("function").and_then(|f| f.get("parameters")))
253 .cloned()
254 .unwrap_or_else(|| serde_json::json!({"type": "object"}));
255 let output_schema = tool
256 .get("outputSchema")
257 .or_else(|| tool.get("output_schema"))
258 .or_else(|| tool.get("returns"))
259 .or_else(|| {
260 tool.get("function")
261 .and_then(|f| f.get("x-harn-output-schema"))
262 })
263 .cloned();
264 let examples = tool
265 .get("examples")
266 .and_then(Value::as_array)
267 .cloned()
268 .unwrap_or_default();
269 entries.push(BindingManifestEntry {
270 name: name.to_string(),
271 binding,
272 namespace: tool
273 .get("namespace")
274 .and_then(Value::as_str)
275 .map(ToOwned::to_owned),
276 description: tool
277 .get("description")
278 .or_else(|| tool.get("function").and_then(|f| f.get("description")))
279 .and_then(Value::as_str)
280 .filter(|s| !s.is_empty())
281 .map(ToOwned::to_owned),
282 input_schema,
283 output_schema,
284 side_effect_level,
285 capabilities: annotations.capabilities.clone(),
286 path_args: annotations.arg_schema.path_params.clone(),
287 annotations,
288 examples,
289 source,
290 deferred,
291 policy,
292 metadata: tool
293 .get("metadata")
294 .or_else(|| tool.get("_meta"))
295 .cloned()
296 .unwrap_or_else(|| Value::Object(serde_json::Map::new())),
297 });
298 }
299 BindingManifest::new(entries, options.side_effect_ceiling)
300}
301
302fn tool_surface_entries(value: &Value) -> Vec<Value> {
303 match value {
304 Value::Array(items) => items.clone(),
305 Value::Object(map) => {
306 if let Some(Value::Array(items)) = map.get("tools") {
307 return items.clone();
308 }
309 if map.get("name").and_then(Value::as_str).is_some() {
310 return vec![value.clone()];
311 }
312 Vec::new()
313 }
314 _ => Vec::new(),
315 }
316}
317
318fn binding_source(tool: &Value) -> String {
319 if tool
320 .get("defer_loading")
321 .and_then(Value::as_bool)
322 .unwrap_or(false)
323 {
324 return "deferred".to_string();
325 }
326 if let Some(executor) = tool.get("executor").and_then(Value::as_str) {
327 return executor.to_string();
328 }
329 if tool.get("_mcp_server").is_some() || tool.get("mcp_server").is_some() {
330 return "mcp_server".to_string();
331 }
332 if tool.get("function").is_some() {
333 return "provider_native".to_string();
334 }
335 "harn".to_string()
336}
337
338fn unique_binding_identifier(name: &str, used: &mut BTreeSet<String>) -> String {
339 let base = sanitize_binding_identifier(name);
340 if used.insert(base.clone()) {
341 return base;
342 }
343 for index in 2.. {
344 let candidate = format!("{base}_{index}");
345 if used.insert(candidate.clone()) {
346 return candidate;
347 }
348 }
349 unreachable!("unbounded identifier suffix search")
350}
351
352fn sanitize_binding_identifier(name: &str) -> String {
353 let mut out = String::new();
354 for (idx, ch) in name.chars().enumerate() {
355 if ch == '_' || ch.is_ascii_alphanumeric() {
356 if idx == 0 && ch.is_ascii_digit() {
357 out.push_str("tool_");
358 }
359 out.push(ch);
360 } else {
361 out.push('_');
362 }
363 }
364 while out.contains("__") {
365 out = out.replace("__", "_");
366 }
367 let out = out.trim_matches('_').to_string();
368 let out = if out.is_empty() {
369 "tool".to_string()
370 } else {
371 out
372 };
373 if HARN_KEYWORDS.contains(&out.as_str()) {
374 format!("tool_{out}")
375 } else {
376 out
377 }
378}
379
380const HARN_KEYWORDS: &[&str] = &[
381 "agent",
382 "as",
383 "await",
384 "break",
385 "catch",
386 "continue",
387 "defer",
388 "else",
389 "enum",
390 "false",
391 "fn",
392 "for",
393 "if",
394 "impl",
395 "import",
396 "in",
397 "interface",
398 "let",
399 "match",
400 "nil",
401 "pipeline",
402 "pub",
403 "return",
404 "skill",
405 "spawn",
406 "struct",
407 "throw",
408 "true",
409 "try",
410 "type",
411 "var",
412 "while",
413];