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: binding_metadata(&tool),
293 });
294 }
295 BindingManifest::new(entries, options.side_effect_ceiling)
296}
297
298fn tool_surface_entries(value: &Value) -> Vec<Value> {
299 match value {
300 Value::Array(items) => items.clone(),
301 Value::Object(map) => {
302 if let Some(Value::Array(items)) = map.get("tools") {
303 return items.clone();
304 }
305 if map.get("name").and_then(Value::as_str).is_some() {
306 return vec![value.clone()];
307 }
308 Vec::new()
309 }
310 _ => Vec::new(),
311 }
312}
313
314fn binding_source(tool: &Value) -> String {
315 if let Some(executor) = tool.get("executor").and_then(Value::as_str) {
316 return executor.to_string();
317 }
318 if tool.get("_mcp_server").is_some() || tool.get("mcp_server").is_some() {
319 return "mcp_server".to_string();
320 }
321 if tool.get("function").is_some() {
322 return "provider_native".to_string();
323 }
324 if tool
325 .get("defer_loading")
326 .and_then(Value::as_bool)
327 .unwrap_or(false)
328 {
329 return "deferred".to_string();
330 }
331 "harn".to_string()
332}
333
334fn binding_metadata(tool: &Value) -> Value {
335 let mut metadata = tool
336 .get("metadata")
337 .or_else(|| tool.get("_meta"))
338 .and_then(Value::as_object)
339 .cloned()
340 .unwrap_or_default();
341 for key in ["_mcp_server", "mcp_server", "_mcp_tool_name"] {
342 if let Some(value) = tool.get(key) {
343 metadata
344 .entry(key.to_string())
345 .or_insert_with(|| value.clone());
346 }
347 }
348 Value::Object(metadata)
349}
350
351fn unique_binding_identifier(name: &str, used: &mut BTreeSet<String>) -> String {
352 let base = sanitize_binding_identifier(name);
353 if used.insert(base.clone()) {
354 return base;
355 }
356 for index in 2.. {
357 let candidate = format!("{base}_{index}");
358 if used.insert(candidate.clone()) {
359 return candidate;
360 }
361 }
362 unreachable!("unbounded identifier suffix search")
363}
364
365fn sanitize_binding_identifier(name: &str) -> String {
366 let mut out = String::new();
367 for (idx, ch) in name.chars().enumerate() {
368 if ch == '_' || ch.is_ascii_alphanumeric() {
369 if idx == 0 && ch.is_ascii_digit() {
370 out.push_str("tool_");
371 }
372 out.push(ch);
373 } else {
374 out.push('_');
375 }
376 }
377 while out.contains("__") {
378 out = out.replace("__", "_");
379 }
380 let out = out.trim_matches('_').to_string();
381 let out = if out.is_empty() {
382 "tool".to_string()
383 } else {
384 out
385 };
386 if HARN_KEYWORDS.contains(&out.as_str()) {
387 format!("tool_{out}")
388 } else {
389 out
390 }
391}
392
393const HARN_KEYWORDS: &[&str] = &[
394 "agent",
395 "as",
396 "await",
397 "break",
398 "catch",
399 "continue",
400 "defer",
401 "else",
402 "enum",
403 "false",
404 "fn",
405 "for",
406 "if",
407 "impl",
408 "import",
409 "in",
410 "interface",
411 "let",
412 "match",
413 "nil",
414 "pipeline",
415 "pub",
416 "return",
417 "skill",
418 "spawn",
419 "struct",
420 "throw",
421 "true",
422 "try",
423 "type",
424 "var",
425 "while",
426];