1use reqwest::blocking::Client;
5use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9pub mod canonical;
10pub mod client;
11pub mod reason_codes;
12pub mod types_gen;
13pub use types_gen::*;
14
15#[cfg(feature = "codegen")]
17pub mod generated {
18 pub mod kernel {
19 include!("generated/helm.kernel.v1.rs");
20 }
21 pub mod authority {
22 include!("generated/helm.authority.v1.rs");
23 }
24 pub mod effects {
25 include!("generated/helm.effects.v1.rs");
26 }
27 pub mod intervention {
28 include!("generated/helm.intervention.v1.rs");
29 }
30 pub mod truth {
31 include!("generated/helm.truth.v1.rs");
32 }
33 pub mod errors {
34 include!("generated/helm.errors.v1.rs");
35 }
36}
37
38const ERROR_INTERNAL: &str = "ERROR_INTERNAL";
40
41#[derive(Debug, Default)]
43pub struct HelmApiError {
44 pub status: u16,
45 pub message: String,
46 pub reason_code: ReasonCode,
49 pub code: Option<String>,
51 pub retryable: bool,
53}
54
55impl std::fmt::Display for HelmApiError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 write!(
58 f,
59 "HELM API {}: {} ({})",
60 self.status, self.message, self.reason_code
61 )
62 }
63}
64
65impl std::error::Error for HelmApiError {}
66
67fn api_error(status: u16, body: serde_json::Value) -> HelmApiError {
70 let text = |v: &serde_json::Value| v.as_str().filter(|s| !s.is_empty()).map(str::to_owned);
71 if !body["code"].is_string() && !body["error"].is_object() {
72 return HelmApiError {
73 status,
74 message: "unknown error".into(),
75 reason_code: ERROR_INTERNAL.into(),
76 ..Default::default()
77 };
78 }
79 let detail = body["details"]
80 .as_array()
81 .and_then(|d| d.iter().find(|d| d["type"] == "helm.errors.v1.ErrorDetail"))
82 .map(|d| &d["debug"]);
83 HelmApiError {
84 status,
85 message: text(&body["message"])
86 .or_else(|| text(&body["detail"]))
87 .or_else(|| text(&body["error"]["message"]))
88 .unwrap_or_else(|| "unknown error".into()),
89 reason_code: detail
90 .and_then(|d| text(&d["reason_code"]))
91 .or_else(|| text(&body["error"]["reason_code"]))
92 .unwrap_or_default(),
93 code: text(&body["code"]),
94 retryable: detail
95 .and_then(|d| d["retryable"].as_bool())
96 .unwrap_or(false),
97 }
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize)]
101pub struct EvidenceEnvelopeExportRequest {
102 pub manifest_id: String,
103 pub envelope: String,
104 pub native_evidence_hash: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub subject: Option<String>,
107 #[serde(default, skip_serializing_if = "is_false")]
108 pub experimental: bool,
109}
110
111fn is_false(value: &bool) -> bool {
112 !*value
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize)]
116pub struct EvidenceEnvelopeManifest {
117 pub manifest_id: String,
118 pub envelope: String,
119 pub native_evidence_hash: String,
120 pub native_authority: bool,
121 pub created_at: String,
122 #[serde(default)]
123 pub subject: Option<String>,
124 #[serde(default)]
125 pub statement_hash: Option<String>,
126 #[serde(default)]
127 pub payload_type: Option<String>,
128 #[serde(default)]
129 pub payload_hash: Option<String>,
130 #[serde(default)]
131 pub experimental: bool,
132 #[serde(default)]
133 pub manifest_hash: Option<String>,
134}
135
136pub type EvidenceEnvelopePayload = serde_json::Value;
137pub type ApprovalWebAuthnChallenge = serde_json::Value;
138pub type ApprovalWebAuthnAssertion = serde_json::Value;
139
140#[derive(Clone, Debug, Serialize, Deserialize)]
141pub struct NegativeBoundaryVector {
142 pub id: String,
143 pub category: String,
144 pub trigger: String,
145 pub expected_verdict: String,
146 pub expected_reason_code: String,
147 pub must_emit_receipt: bool,
148 pub must_not_dispatch: bool,
149 #[serde(default)]
150 pub must_bind_evidence: Vec<String>,
151}
152
153#[derive(Clone, Debug, Serialize, Deserialize)]
154pub struct McpRegistryDiscoverRequest {
155 pub server_id: String,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub name: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub transport: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub endpoint: Option<String>,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub tool_names: Vec<String>,
164 #[serde(default = "default_mcp_risk")]
165 pub risk: String,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub reason: Option<String>,
168}
169
170fn default_mcp_risk() -> String {
171 "unknown".to_string()
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize)]
175pub struct McpRegistryApprovalRequest {
176 pub server_id: String,
177 pub approver_id: String,
178 pub approval_receipt_id: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub reason: Option<String>,
181}
182
183#[derive(Clone, Debug, Serialize, Deserialize)]
184pub struct McpQuarantineRecord {
185 pub server_id: String,
186 pub risk: String,
187 pub state: String,
188 pub discovered_at: String,
189 #[serde(default)]
190 pub name: Option<String>,
191 #[serde(default)]
192 pub transport: Option<String>,
193 #[serde(default)]
194 pub endpoint: Option<String>,
195 #[serde(default)]
196 pub tool_names: Vec<String>,
197 #[serde(default)]
198 pub approved_at: Option<String>,
199 #[serde(default)]
200 pub approved_by: Option<String>,
201 #[serde(default)]
202 pub approval_receipt_id: Option<String>,
203 #[serde(default)]
204 pub revoked_at: Option<String>,
205 #[serde(default)]
206 pub expires_at: Option<String>,
207 #[serde(default)]
208 pub reason: Option<String>,
209}
210
211#[derive(Clone, Debug, Serialize, Deserialize)]
212pub struct SandboxBackendProfile {
213 pub name: String,
214 pub kind: String,
215 pub runtime: String,
216 pub hosted: bool,
217 pub deny_network_by_default: bool,
218 pub native_isolation: bool,
219 #[serde(default)]
220 pub experimental: bool,
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize)]
224pub struct SandboxGrant {
225 pub grant_id: String,
226 pub runtime: String,
227 pub profile: String,
228 pub env: serde_json::Value,
229 pub network: serde_json::Value,
230 pub declared_at: String,
231 #[serde(default)]
232 pub runtime_version: Option<String>,
233 #[serde(default)]
234 pub image_digest: Option<String>,
235 #[serde(default)]
236 pub template_digest: Option<String>,
237 #[serde(default)]
238 pub filesystem_preopens: Vec<serde_json::Value>,
239 #[serde(default)]
240 pub limits: Option<serde_json::Value>,
241 #[serde(default)]
242 pub policy_epoch: Option<String>,
243 #[serde(default)]
244 pub grant_hash: Option<String>,
245}
246
247#[derive(Clone, Debug, Serialize, Deserialize)]
248#[serde(untagged)]
249pub enum SandboxGrantInspection {
250 Profiles(Vec<SandboxBackendProfile>),
251 Grant(SandboxGrant),
252}
253
254pub struct HelmClient {
256 base_url: String,
257 client: Client,
258}
259
260impl HelmClient {
261 pub fn new(base_url: &str) -> Self {
263 Self::with_auth(base_url, None, None, None)
264 }
265
266 pub fn with_auth(
268 base_url: &str,
269 api_key: Option<&str>,
270 tenant_id: Option<&str>,
271 principal_id: Option<&str>,
272 ) -> Self {
273 let mut headers = HeaderMap::new();
274 if let Some(api_key) = api_key.filter(|value| !value.trim().is_empty()) {
275 headers.insert(
276 AUTHORIZATION,
277 HeaderValue::from_str(&format!("Bearer {api_key}"))
278 .expect("HELM API key must be a valid HTTP header value"),
279 );
280 }
281 if let Some(tenant_id) = tenant_id.filter(|value| !value.trim().is_empty()) {
282 headers.insert(
283 "X-Helm-Tenant-ID",
284 HeaderValue::from_str(tenant_id)
285 .expect("HELM tenant ID must be a valid HTTP header value"),
286 );
287 }
288 if let Some(principal_id) = principal_id.filter(|value| !value.trim().is_empty()) {
289 headers.insert(
290 "X-Helm-Principal-ID",
291 HeaderValue::from_str(principal_id)
292 .expect("HELM principal ID must be a valid HTTP header value"),
293 );
294 }
295 Self {
296 base_url: base_url.trim_end_matches('/').to_string(),
297 client: Client::builder()
298 .timeout(Duration::from_secs(30))
299 .default_headers(headers)
300 .build()
301 .expect("failed to build HTTP client"),
302 }
303 }
304
305 fn url(&self, path: &str) -> String {
306 format!("{}{}", self.base_url, path)
307 }
308
309 fn check(
310 &self,
311 resp: reqwest::blocking::Response,
312 ) -> Result<reqwest::blocking::Response, HelmApiError> {
313 if resp.status().is_success() {
314 return Ok(resp);
315 }
316 let status = resp.status().as_u16();
317 Err(api_error(status, resp.json().unwrap_or_default()))
318 }
319
320 fn get_value(&self, path: &str) -> Result<serde_json::Value, HelmApiError> {
321 let resp = self
322 .client
323 .get(self.url(path))
324 .send()
325 .map_err(|e| HelmApiError {
326 status: 0,
327 message: e.to_string(),
328 reason_code: ERROR_INTERNAL.into(),
329 ..Default::default()
330 })?;
331 let resp = self.check(resp)?;
332 resp.json().map_err(|e| HelmApiError {
333 status: 0,
334 message: e.to_string(),
335 reason_code: ERROR_INTERNAL.into(),
336 ..Default::default()
337 })
338 }
339
340 fn post_value<T: Serialize>(
341 &self,
342 path: &str,
343 body: &T,
344 ) -> Result<serde_json::Value, HelmApiError> {
345 let resp = self
346 .client
347 .post(self.url(path))
348 .json(body)
349 .send()
350 .map_err(|e| HelmApiError {
351 status: 0,
352 message: e.to_string(),
353 reason_code: ERROR_INTERNAL.into(),
354 ..Default::default()
355 })?;
356 let resp = self.check(resp)?;
357 resp.json().map_err(|e| HelmApiError {
358 status: 0,
359 message: e.to_string(),
360 reason_code: ERROR_INTERNAL.into(),
361 ..Default::default()
362 })
363 }
364
365 fn put_value<T: Serialize>(
366 &self,
367 path: &str,
368 body: &T,
369 ) -> Result<serde_json::Value, HelmApiError> {
370 let resp = self
371 .client
372 .put(self.url(path))
373 .json(body)
374 .send()
375 .map_err(|e| HelmApiError {
376 status: 0,
377 message: e.to_string(),
378 reason_code: ERROR_INTERNAL.into(),
379 ..Default::default()
380 })?;
381 let resp = self.check(resp)?;
382 resp.json().map_err(|e| HelmApiError {
383 status: 0,
384 message: e.to_string(),
385 reason_code: ERROR_INTERNAL.into(),
386 ..Default::default()
387 })
388 }
389
390 pub fn get_boundary_status(&self) -> Result<serde_json::Value, HelmApiError> {
391 self.get_value("/api/v1/boundary/status")
392 }
393
394 pub fn list_boundary_capabilities(&self) -> Result<serde_json::Value, HelmApiError> {
395 self.get_value("/api/v1/boundary/capabilities")
396 }
397
398 pub fn list_boundary_records(&self) -> Result<serde_json::Value, HelmApiError> {
399 self.get_value("/api/v1/boundary/records")
400 }
401
402 pub fn get_boundary_record(&self, record_id: &str) -> Result<serde_json::Value, HelmApiError> {
403 self.get_value(&format!(
404 "/api/v1/boundary/records/{}",
405 encode_query(record_id)
406 ))
407 }
408
409 pub fn verify_boundary_record(
410 &self,
411 record_id: &str,
412 ) -> Result<serde_json::Value, HelmApiError> {
413 self.post_value(
414 &format!(
415 "/api/v1/boundary/records/{}/verify",
416 encode_query(record_id)
417 ),
418 &serde_json::json!({}),
419 )
420 }
421
422 pub fn list_boundary_checkpoints(&self) -> Result<serde_json::Value, HelmApiError> {
423 self.get_value("/api/v1/boundary/checkpoints")
424 }
425
426 pub fn create_boundary_checkpoint(&self) -> Result<serde_json::Value, HelmApiError> {
427 self.post_value("/api/v1/boundary/checkpoints", &serde_json::json!({}))
428 }
429
430 pub fn verify_boundary_checkpoint(
431 &self,
432 checkpoint_id: &str,
433 ) -> Result<serde_json::Value, HelmApiError> {
434 self.post_value(
435 &format!(
436 "/api/v1/boundary/checkpoints/{}/verify",
437 encode_query(checkpoint_id)
438 ),
439 &serde_json::json!({}),
440 )
441 }
442
443 pub fn chat_completions(
445 &self,
446 req: &ChatCompletionRequest,
447 ) -> Result<ChatCompletionResponse, HelmApiError> {
448 let resp = self
449 .client
450 .post(self.url("/v1/chat/completions"))
451 .json(req)
452 .send()
453 .map_err(|e| HelmApiError {
454 status: 0,
455 message: e.to_string(),
456 reason_code: ERROR_INTERNAL.into(),
457 ..Default::default()
458 })?;
459 let resp = self.check(resp)?;
460 resp.json().map_err(|e| HelmApiError {
461 status: 0,
462 message: e.to_string(),
463 reason_code: ERROR_INTERNAL.into(),
464 ..Default::default()
465 })
466 }
467
468 #[deprecated(note = "use evaluate_decision_v5 for the typed V5 contract")]
470 pub fn evaluate_decision<T: Serialize>(
471 &self,
472 req: &T,
473 ) -> Result<serde_json::Value, HelmApiError> {
474 self.post_value("/api/v1/evaluate", req)
475 }
476
477 pub fn evaluate_decision_v5(
479 &self,
480 req: &EvaluateRequest,
481 ) -> Result<EvaluateResponse, HelmApiError> {
482 for (field, value) in [
483 ("tool", req.tool.as_deref()),
484 ("effect_level", req.effect_level.as_deref()),
485 ("session_id", req.session_id.as_deref()),
486 ] {
487 if value.map(str::trim).is_none_or(str::is_empty) {
488 return Err(HelmApiError {
489 status: 0,
490 message: format!("evaluate_decision_v5 requires a non-blank {field}"),
491 reason_code: ERROR_INTERNAL.into(),
492 ..Default::default()
493 });
494 }
495 }
496 let resp = self
497 .client
498 .post(self.url("/api/v1/evaluate"))
499 .json(req)
500 .send()
501 .map_err(|e| HelmApiError {
502 status: 0,
503 message: e.to_string(),
504 reason_code: ERROR_INTERNAL.into(),
505 ..Default::default()
506 })?;
507 let resp = self.check(resp)?;
508 resp.json().map_err(|e| HelmApiError {
509 status: 0,
510 message: e.to_string(),
511 reason_code: ERROR_INTERNAL.into(),
512 ..Default::default()
513 })
514 }
515
516 pub fn approve_intent(&self, req: &ApprovalRequest) -> Result<Receipt, HelmApiError> {
518 let resp = self
519 .client
520 .post(self.url("/api/v1/kernel/approve"))
521 .json(req)
522 .send()
523 .map_err(|e| HelmApiError {
524 status: 0,
525 message: e.to_string(),
526 reason_code: ERROR_INTERNAL.into(),
527 ..Default::default()
528 })?;
529 let resp = self.check(resp)?;
530 resp.json().map_err(|e| HelmApiError {
531 status: 0,
532 message: e.to_string(),
533 reason_code: ERROR_INTERNAL.into(),
534 ..Default::default()
535 })
536 }
537
538 pub fn list_sessions(&self) -> Result<Vec<Session>, HelmApiError> {
540 let resp = self
541 .client
542 .get(self.url("/api/v1/proofgraph/sessions"))
543 .send()
544 .map_err(|e| HelmApiError {
545 status: 0,
546 message: e.to_string(),
547 reason_code: ERROR_INTERNAL.into(),
548 ..Default::default()
549 })?;
550 let resp = self.check(resp)?;
551 resp.json().map_err(|e| HelmApiError {
552 status: 0,
553 message: e.to_string(),
554 reason_code: ERROR_INTERNAL.into(),
555 ..Default::default()
556 })
557 }
558
559 pub fn get_receipts(&self, session_id: &str) -> Result<Vec<Receipt>, HelmApiError> {
561 let resp = self
562 .client
563 .get(self.url(&format!(
564 "/api/v1/proofgraph/sessions/{}/receipts",
565 session_id
566 )))
567 .send()
568 .map_err(|e| HelmApiError {
569 status: 0,
570 message: e.to_string(),
571 reason_code: ERROR_INTERNAL.into(),
572 ..Default::default()
573 })?;
574 let resp = self.check(resp)?;
575 resp.json().map_err(|e| HelmApiError {
576 status: 0,
577 message: e.to_string(),
578 reason_code: ERROR_INTERNAL.into(),
579 ..Default::default()
580 })
581 }
582
583 pub fn export_evidence(&self, session_id: Option<&str>) -> Result<Vec<u8>, HelmApiError> {
585 let body = serde_json::json!({
586 "session_id": session_id,
587 "format": "tar.gz"
588 });
589 let resp = self
590 .client
591 .post(self.url("/api/v1/evidence/export"))
592 .json(&body)
593 .send()
594 .map_err(|e| HelmApiError {
595 status: 0,
596 message: e.to_string(),
597 reason_code: ERROR_INTERNAL.into(),
598 ..Default::default()
599 })?;
600 let resp = self.check(resp)?;
601 resp.bytes().map(|b| b.to_vec()).map_err(|e| HelmApiError {
602 status: 0,
603 message: e.to_string(),
604 reason_code: ERROR_INTERNAL.into(),
605 ..Default::default()
606 })
607 }
608
609 pub fn verify_evidence(&self, bundle: &[u8]) -> Result<VerificationResult, HelmApiError> {
611 let form = reqwest::blocking::multipart::Form::new().part(
612 "bundle",
613 reqwest::blocking::multipart::Part::bytes(bundle.to_vec())
614 .file_name("pack.tar.gz")
615 .mime_str("application/octet-stream")
616 .unwrap(),
617 );
618 let resp = self
619 .client
620 .post(self.url("/api/v1/evidence/verify"))
621 .multipart(form)
622 .send()
623 .map_err(|e| HelmApiError {
624 status: 0,
625 message: e.to_string(),
626 reason_code: ERROR_INTERNAL.into(),
627 ..Default::default()
628 })?;
629 let resp = self.check(resp)?;
630 resp.json().map_err(|e| HelmApiError {
631 status: 0,
632 message: e.to_string(),
633 reason_code: ERROR_INTERNAL.into(),
634 ..Default::default()
635 })
636 }
637
638 pub fn replay_verify(&self, bundle: &[u8]) -> Result<VerificationResult, HelmApiError> {
640 let form = reqwest::blocking::multipart::Form::new().part(
641 "bundle",
642 reqwest::blocking::multipart::Part::bytes(bundle.to_vec())
643 .file_name("pack.tar.gz")
644 .mime_str("application/octet-stream")
645 .unwrap(),
646 );
647 let resp = self
648 .client
649 .post(self.url("/api/v1/replay/verify"))
650 .multipart(form)
651 .send()
652 .map_err(|e| HelmApiError {
653 status: 0,
654 message: e.to_string(),
655 reason_code: ERROR_INTERNAL.into(),
656 ..Default::default()
657 })?;
658 let resp = self.check(resp)?;
659 resp.json().map_err(|e| HelmApiError {
660 status: 0,
661 message: e.to_string(),
662 reason_code: ERROR_INTERNAL.into(),
663 ..Default::default()
664 })
665 }
666
667 pub fn create_evidence_envelope_manifest(
669 &self,
670 req: &EvidenceEnvelopeExportRequest,
671 ) -> Result<EvidenceEnvelopeManifest, HelmApiError> {
672 let resp = self
673 .client
674 .post(self.url("/api/v1/evidence/envelopes"))
675 .json(req)
676 .send()
677 .map_err(|e| HelmApiError {
678 status: 0,
679 message: e.to_string(),
680 reason_code: ERROR_INTERNAL.into(),
681 ..Default::default()
682 })?;
683 let resp = self.check(resp)?;
684 resp.json().map_err(|e| HelmApiError {
685 status: 0,
686 message: e.to_string(),
687 reason_code: ERROR_INTERNAL.into(),
688 ..Default::default()
689 })
690 }
691
692 pub fn list_evidence_envelope_manifests(&self) -> Result<serde_json::Value, HelmApiError> {
693 self.get_value("/api/v1/evidence/envelopes")
694 }
695
696 pub fn get_evidence_envelope_manifest(
697 &self,
698 manifest_id: &str,
699 ) -> Result<serde_json::Value, HelmApiError> {
700 self.get_value(&format!(
701 "/api/v1/evidence/envelopes/{}",
702 encode_query(manifest_id)
703 ))
704 }
705
706 pub fn get_evidence_envelope_payload(
707 &self,
708 manifest_id: &str,
709 ) -> Result<EvidenceEnvelopePayload, HelmApiError> {
710 self.get_value(&format!(
711 "/api/v1/evidence/envelopes/{}/payload",
712 encode_query(manifest_id)
713 ))
714 }
715
716 pub fn verify_evidence_envelope_manifest(
717 &self,
718 manifest_id: &str,
719 ) -> Result<serde_json::Value, HelmApiError> {
720 self.post_value(
721 &format!(
722 "/api/v1/evidence/envelopes/{}/verify",
723 encode_query(manifest_id)
724 ),
725 &serde_json::json!({}),
726 )
727 }
728
729 pub fn get_receipt(&self, receipt_hash: &str) -> Result<Receipt, HelmApiError> {
731 let resp = self
732 .client
733 .get(self.url(&format!("/api/v1/proofgraph/receipts/{}", receipt_hash)))
734 .send()
735 .map_err(|e| HelmApiError {
736 status: 0,
737 message: e.to_string(),
738 reason_code: ERROR_INTERNAL.into(),
739 ..Default::default()
740 })?;
741 let resp = self.check(resp)?;
742 resp.json().map_err(|e| HelmApiError {
743 status: 0,
744 message: e.to_string(),
745 reason_code: ERROR_INTERNAL.into(),
746 ..Default::default()
747 })
748 }
749
750 pub fn list_negative_conformance_vectors(
752 &self,
753 ) -> Result<Vec<NegativeBoundaryVector>, HelmApiError> {
754 let resp = self
755 .client
756 .get(self.url("/api/v1/conformance/negative"))
757 .send()
758 .map_err(|e| HelmApiError {
759 status: 0,
760 message: e.to_string(),
761 reason_code: ERROR_INTERNAL.into(),
762 ..Default::default()
763 })?;
764 let resp = self.check(resp)?;
765 resp.json().map_err(|e| HelmApiError {
766 status: 0,
767 message: e.to_string(),
768 reason_code: ERROR_INTERNAL.into(),
769 ..Default::default()
770 })
771 }
772
773 pub fn list_conformance_vectors(&self) -> Result<serde_json::Value, HelmApiError> {
774 self.get_value("/api/v1/conformance/vectors")
775 }
776
777 pub fn list_mcp_registry(&self) -> Result<Vec<McpQuarantineRecord>, HelmApiError> {
779 let resp = self
780 .client
781 .get(self.url("/api/v1/mcp/registry"))
782 .send()
783 .map_err(|e| HelmApiError {
784 status: 0,
785 message: e.to_string(),
786 reason_code: ERROR_INTERNAL.into(),
787 ..Default::default()
788 })?;
789 let resp = self.check(resp)?;
790 resp.json().map_err(|e| HelmApiError {
791 status: 0,
792 message: e.to_string(),
793 reason_code: ERROR_INTERNAL.into(),
794 ..Default::default()
795 })
796 }
797
798 pub fn discover_mcp_server(
800 &self,
801 req: &McpRegistryDiscoverRequest,
802 ) -> Result<McpQuarantineRecord, HelmApiError> {
803 let resp = self
804 .client
805 .post(self.url("/api/v1/mcp/registry"))
806 .json(req)
807 .send()
808 .map_err(|e| HelmApiError {
809 status: 0,
810 message: e.to_string(),
811 reason_code: ERROR_INTERNAL.into(),
812 ..Default::default()
813 })?;
814 let resp = self.check(resp)?;
815 resp.json().map_err(|e| HelmApiError {
816 status: 0,
817 message: e.to_string(),
818 reason_code: ERROR_INTERNAL.into(),
819 ..Default::default()
820 })
821 }
822
823 pub fn approve_mcp_server(
825 &self,
826 req: &McpRegistryApprovalRequest,
827 ) -> Result<McpQuarantineRecord, HelmApiError> {
828 let resp = self
829 .client
830 .post(self.url("/api/v1/mcp/registry/approve"))
831 .json(req)
832 .send()
833 .map_err(|e| HelmApiError {
834 status: 0,
835 message: e.to_string(),
836 reason_code: ERROR_INTERNAL.into(),
837 ..Default::default()
838 })?;
839 let resp = self.check(resp)?;
840 resp.json().map_err(|e| HelmApiError {
841 status: 0,
842 message: e.to_string(),
843 reason_code: ERROR_INTERNAL.into(),
844 ..Default::default()
845 })
846 }
847
848 pub fn get_mcp_registry_record(
849 &self,
850 server_id: &str,
851 ) -> Result<McpQuarantineRecord, HelmApiError> {
852 let resp = self
853 .client
854 .get(self.url(&format!("/api/v1/mcp/registry/{}", encode_query(server_id))))
855 .send()
856 .map_err(|e| HelmApiError {
857 status: 0,
858 message: e.to_string(),
859 reason_code: ERROR_INTERNAL.into(),
860 ..Default::default()
861 })?;
862 let resp = self.check(resp)?;
863 resp.json().map_err(|e| HelmApiError {
864 status: 0,
865 message: e.to_string(),
866 reason_code: ERROR_INTERNAL.into(),
867 ..Default::default()
868 })
869 }
870
871 pub fn approve_mcp_registry_record(
872 &self,
873 server_id: &str,
874 req: &McpRegistryApprovalRequest,
875 ) -> Result<McpQuarantineRecord, HelmApiError> {
876 let resp = self
877 .client
878 .post(self.url(&format!(
879 "/api/v1/mcp/registry/{}/approve",
880 encode_query(server_id)
881 )))
882 .json(req)
883 .send()
884 .map_err(|e| HelmApiError {
885 status: 0,
886 message: e.to_string(),
887 reason_code: ERROR_INTERNAL.into(),
888 ..Default::default()
889 })?;
890 let resp = self.check(resp)?;
891 resp.json().map_err(|e| HelmApiError {
892 status: 0,
893 message: e.to_string(),
894 reason_code: ERROR_INTERNAL.into(),
895 ..Default::default()
896 })
897 }
898
899 pub fn revoke_mcp_registry_record(
900 &self,
901 server_id: &str,
902 reason: Option<&str>,
903 ) -> Result<McpQuarantineRecord, HelmApiError> {
904 let body = serde_json::json!({ "reason": reason.unwrap_or("") });
905 let resp = self
906 .client
907 .post(self.url(&format!(
908 "/api/v1/mcp/registry/{}/revoke",
909 encode_query(server_id)
910 )))
911 .json(&body)
912 .send()
913 .map_err(|e| HelmApiError {
914 status: 0,
915 message: e.to_string(),
916 reason_code: ERROR_INTERNAL.into(),
917 ..Default::default()
918 })?;
919 let resp = self.check(resp)?;
920 resp.json().map_err(|e| HelmApiError {
921 status: 0,
922 message: e.to_string(),
923 reason_code: ERROR_INTERNAL.into(),
924 ..Default::default()
925 })
926 }
927
928 pub fn scan_mcp_server<T: Serialize>(
929 &self,
930 req: &T,
931 ) -> Result<serde_json::Value, HelmApiError> {
932 self.post_value("/api/v1/mcp/scan", req)
933 }
934
935 pub fn list_mcp_auth_profiles(&self) -> Result<serde_json::Value, HelmApiError> {
936 self.get_value("/api/v1/mcp/auth-profiles")
937 }
938
939 pub fn put_mcp_auth_profile<T: Serialize>(
940 &self,
941 profile_id: &str,
942 profile: &T,
943 ) -> Result<serde_json::Value, HelmApiError> {
944 self.put_value(
945 &format!("/api/v1/mcp/auth-profiles/{}", encode_query(profile_id)),
946 profile,
947 )
948 }
949
950 pub fn authorize_mcp_call<T: Serialize>(
951 &self,
952 req: &T,
953 ) -> Result<serde_json::Value, HelmApiError> {
954 self.post_value("/api/v1/mcp/authorize-call", req)
955 }
956
957 pub fn inspect_sandbox_grants(
959 &self,
960 runtime: Option<&str>,
961 profile: Option<&str>,
962 policy_epoch: Option<&str>,
963 ) -> Result<SandboxGrantInspection, HelmApiError> {
964 let mut path = "/api/v1/sandbox/grants/inspect".to_string();
965 let mut params = Vec::new();
966 if let Some(runtime) = runtime {
967 params.push(format!("runtime={}", encode_query(runtime)));
968 }
969 if let Some(profile) = profile {
970 params.push(format!("profile={}", encode_query(profile)));
971 }
972 if let Some(policy_epoch) = policy_epoch {
973 params.push(format!("policy_epoch={}", encode_query(policy_epoch)));
974 }
975 if !params.is_empty() {
976 path.push('?');
977 path.push_str(¶ms.join("&"));
978 }
979 let resp = self
980 .client
981 .get(self.url(&path))
982 .send()
983 .map_err(|e| HelmApiError {
984 status: 0,
985 message: e.to_string(),
986 reason_code: ERROR_INTERNAL.into(),
987 ..Default::default()
988 })?;
989 let resp = self.check(resp)?;
990 resp.json().map_err(|e| HelmApiError {
991 status: 0,
992 message: e.to_string(),
993 reason_code: ERROR_INTERNAL.into(),
994 ..Default::default()
995 })
996 }
997
998 pub fn list_sandbox_profiles(&self) -> Result<serde_json::Value, HelmApiError> {
999 self.get_value("/api/v1/sandbox/profiles")
1000 }
1001
1002 pub fn list_sandbox_grants(&self) -> Result<serde_json::Value, HelmApiError> {
1003 self.get_value("/api/v1/sandbox/grants")
1004 }
1005
1006 pub fn create_sandbox_grant<T: Serialize>(
1007 &self,
1008 req: &T,
1009 ) -> Result<serde_json::Value, HelmApiError> {
1010 self.post_value("/api/v1/sandbox/grants", req)
1011 }
1012
1013 pub fn get_sandbox_grant(&self, grant_id: &str) -> Result<serde_json::Value, HelmApiError> {
1014 self.get_value(&format!(
1015 "/api/v1/sandbox/grants/{}",
1016 encode_query(grant_id)
1017 ))
1018 }
1019
1020 pub fn verify_sandbox_grant(&self, grant_id: &str) -> Result<serde_json::Value, HelmApiError> {
1021 self.post_value(
1022 &format!("/api/v1/sandbox/grants/{}/verify", encode_query(grant_id)),
1023 &serde_json::json!({}),
1024 )
1025 }
1026
1027 pub fn preflight_sandbox_grant<T: Serialize>(
1028 &self,
1029 req: &T,
1030 ) -> Result<serde_json::Value, HelmApiError> {
1031 self.post_value("/api/v1/sandbox/preflight", req)
1032 }
1033
1034 pub fn list_agent_identities(&self) -> Result<serde_json::Value, HelmApiError> {
1035 self.get_value("/api/v1/identity/agents")
1036 }
1037
1038 pub fn get_authz_health(&self) -> Result<serde_json::Value, HelmApiError> {
1039 self.get_value("/api/v1/authz/health")
1040 }
1041
1042 pub fn check_authz<T: Serialize>(&self, req: &T) -> Result<serde_json::Value, HelmApiError> {
1043 self.post_value("/api/v1/authz/check", req)
1044 }
1045
1046 pub fn list_authz_snapshots(&self) -> Result<serde_json::Value, HelmApiError> {
1047 self.get_value("/api/v1/authz/snapshots")
1048 }
1049
1050 pub fn get_authz_snapshot(&self, snapshot_id: &str) -> Result<serde_json::Value, HelmApiError> {
1051 self.get_value(&format!(
1052 "/api/v1/authz/snapshots/{}",
1053 encode_query(snapshot_id)
1054 ))
1055 }
1056
1057 pub fn list_approval_ceremonies(&self) -> Result<serde_json::Value, HelmApiError> {
1058 self.get_value("/api/v1/approvals")
1059 }
1060
1061 pub fn create_approval_ceremony<T: Serialize>(
1062 &self,
1063 req: &T,
1064 ) -> Result<serde_json::Value, HelmApiError> {
1065 self.post_value("/api/v1/approvals", req)
1066 }
1067
1068 pub fn transition_approval_ceremony<T: Serialize>(
1069 &self,
1070 approval_id: &str,
1071 action: &str,
1072 req: &T,
1073 ) -> Result<serde_json::Value, HelmApiError> {
1074 self.post_value(
1075 &format!(
1076 "/api/v1/approvals/{}/{}",
1077 encode_query(approval_id),
1078 encode_query(action)
1079 ),
1080 req,
1081 )
1082 }
1083
1084 pub fn create_approval_webauthn_challenge<T: Serialize>(
1085 &self,
1086 approval_id: &str,
1087 req: &T,
1088 ) -> Result<ApprovalWebAuthnChallenge, HelmApiError> {
1089 self.post_value(
1090 &format!(
1091 "/api/v1/approvals/{}/webauthn/challenge",
1092 encode_query(approval_id)
1093 ),
1094 req,
1095 )
1096 }
1097
1098 pub fn assert_approval_webauthn_challenge<T: Serialize>(
1099 &self,
1100 approval_id: &str,
1101 req: &T,
1102 ) -> Result<serde_json::Value, HelmApiError> {
1103 self.post_value(
1104 &format!(
1105 "/api/v1/approvals/{}/webauthn/assert",
1106 encode_query(approval_id)
1107 ),
1108 req,
1109 )
1110 }
1111
1112 pub fn list_budget_ceilings(&self) -> Result<serde_json::Value, HelmApiError> {
1113 self.get_value("/api/v1/budgets")
1114 }
1115
1116 pub fn put_budget_ceiling<T: Serialize>(
1117 &self,
1118 budget_id: &str,
1119 req: &T,
1120 ) -> Result<serde_json::Value, HelmApiError> {
1121 self.put_value(&format!("/api/v1/budgets/{}", encode_query(budget_id)), req)
1122 }
1123
1124 pub fn get_coexistence_capabilities(&self) -> Result<serde_json::Value, HelmApiError> {
1125 self.get_value("/api/v1/coexistence/capabilities")
1126 }
1127
1128 pub fn get_telemetry_otel_config(&self) -> Result<serde_json::Value, HelmApiError> {
1129 self.get_value("/api/v1/telemetry/otel/config")
1130 }
1131
1132 pub fn export_telemetry<T: Serialize>(
1133 &self,
1134 req: &T,
1135 ) -> Result<serde_json::Value, HelmApiError> {
1136 self.post_value("/api/v1/telemetry/export", req)
1137 }
1138
1139 pub fn health(&self) -> Result<serde_json::Value, HelmApiError> {
1141 let resp = self
1142 .client
1143 .get(self.url("/healthz"))
1144 .send()
1145 .map_err(|e| HelmApiError {
1146 status: 0,
1147 message: e.to_string(),
1148 reason_code: ERROR_INTERNAL.into(),
1149 ..Default::default()
1150 })?;
1151 let resp = self.check(resp)?;
1152 resp.json().map_err(|e| HelmApiError {
1153 status: 0,
1154 message: e.to_string(),
1155 reason_code: ERROR_INTERNAL.into(),
1156 ..Default::default()
1157 })
1158 }
1159
1160 pub fn version(&self) -> Result<VersionInfo, HelmApiError> {
1162 let resp = self
1163 .client
1164 .get(self.url("/version"))
1165 .send()
1166 .map_err(|e| HelmApiError {
1167 status: 0,
1168 message: e.to_string(),
1169 reason_code: ERROR_INTERNAL.into(),
1170 ..Default::default()
1171 })?;
1172 let resp = self.check(resp)?;
1173 resp.json().map_err(|e| HelmApiError {
1174 status: 0,
1175 message: e.to_string(),
1176 reason_code: ERROR_INTERNAL.into(),
1177 ..Default::default()
1178 })
1179 }
1180}
1181
1182fn encode_query(value: &str) -> String {
1183 value
1184 .bytes()
1185 .flat_map(|b| match b {
1186 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1187 vec![b as char]
1188 }
1189 _ => format!("%{b:02X}").chars().collect(),
1190 })
1191 .collect()
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197 use std::io::{Read, Write};
1198 use std::net::TcpListener;
1199
1200 #[test]
1201 fn test_reason_codes_are_registry_strings() {
1202 assert_eq!(reason_codes::ALL.len(), 122);
1203 assert_eq!(reason_codes::EMERGENCY_STOP_FENCED, "EMERGENCY_STOP_FENCED");
1204 assert!(reason_codes::is_registered(
1205 reason_codes::EMERGENCY_STOP_FENCED
1206 ));
1207 assert!(!reason_codes::is_registered("NOT_A_REGISTERED_CODE"));
1208 }
1209
1210 #[test]
1211 fn test_client_creation() {
1212 let _client = HelmClient::new("http://localhost:8080");
1213 }
1214
1215 #[test]
1216 fn test_authenticated_client_sends_context_headers() {
1217 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1218 let address = listener.local_addr().unwrap();
1219 let server = std::thread::spawn(move || {
1220 let (mut stream, _) = listener.accept().unwrap();
1221 let mut request = Vec::new();
1222 let mut buffer = [0; 1024];
1223 while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
1224 let count = stream.read(&mut buffer).unwrap();
1225 assert!(count > 0, "connection closed before HTTP headers arrived");
1226 request.extend_from_slice(&buffer[..count]);
1227 }
1228 stream
1229 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
1230 .unwrap();
1231 String::from_utf8(request).unwrap()
1232 });
1233
1234 let client = HelmClient::with_auth(
1235 &format!("http://{address}"),
1236 Some("test-api-key"),
1237 Some("tenant-a"),
1238 Some("principal-a"),
1239 );
1240 assert_eq!(client.health().unwrap(), serde_json::json!({}));
1241
1242 let request = server.join().unwrap().to_ascii_lowercase();
1243 assert!(request.contains("authorization: bearer test-api-key"));
1244 assert!(request.contains("x-helm-tenant-id: tenant-a"));
1245 assert!(request.contains("x-helm-principal-id: principal-a"));
1246 }
1247
1248 #[test]
1249 fn test_api_error_reads_the_error_model() {
1250 let body: serde_json::Value = serde_json::from_str(include_str!(
1252 "../../../protocols/specs/errors/error-model-503.json"
1253 ))
1254 .unwrap();
1255 let err = api_error(503, body);
1256 assert_eq!(err.message, "emergency-stop fence active");
1257 assert_eq!(err.reason_code, "EMERGENCY_STOP_FENCED");
1258 assert_eq!(err.code.as_deref(), Some("unavailable"));
1259 assert!(err.retryable);
1260 assert_eq!(
1261 api_error(500, serde_json::Value::Null).reason_code,
1262 ERROR_INTERNAL
1263 );
1264 }
1265
1266 #[test]
1267 fn test_evaluate_decision_v5_requires_canonical_request() {
1268 let request = EvaluateRequest {
1269 tool: Some("read_file".to_string()),
1270 effect_level: Some("read".to_string()),
1271 session_id: Some("session-test".to_string()),
1272 ..EvaluateRequest::new()
1273 };
1274 let encoded = serde_json::to_value(&request).unwrap();
1275 assert_eq!(encoded["tool"], "read_file");
1276 assert_eq!(encoded["effect_level"], "read");
1277 assert_eq!(encoded["session_id"], "session-test");
1278
1279 let client = HelmClient::new("http://127.0.0.1:1");
1280 let blank = EvaluateRequest {
1281 tool: Some("read_file".to_string()),
1282 effect_level: Some("read".to_string()),
1283 session_id: Some(" ".to_string()),
1284 ..EvaluateRequest::new()
1285 };
1286 let err = client.evaluate_decision_v5(&blank).unwrap_err();
1287 assert_eq!(err.status, 0);
1288 assert!(err.message.contains("non-blank session_id"));
1289 }
1290
1291 #[test]
1292 #[allow(deprecated)]
1293 fn test_legacy_evaluate_decision_accepts_dynamic_request() {
1294 let client = HelmClient::new("http://127.0.0.1:1");
1295 let err = client
1296 .evaluate_decision(&serde_json::json!({
1297 "action": "read_file",
1298 "resource": "read",
1299 "context": {"session_id": "legacy-session"},
1300 }))
1301 .unwrap_err();
1302 assert_eq!(err.status, 0);
1303 }
1304
1305 #[test]
1306 fn test_execution_boundary_types_serde() {
1307 let req = EvidenceEnvelopeExportRequest {
1308 manifest_id: "env1".to_string(),
1309 envelope: "dsse".to_string(),
1310 native_evidence_hash: "sha256:native".to_string(),
1311 subject: None,
1312 experimental: false,
1313 };
1314 let json = serde_json::to_string(&req).unwrap();
1315 assert!(json.contains("native_evidence_hash"));
1316
1317 let manifest: EvidenceEnvelopeManifest = serde_json::from_str(
1318 r#"{"manifest_id":"env1","envelope":"dsse","native_evidence_hash":"sha256:native","native_authority":false,"created_at":"2026-05-05T00:00:00Z","payload_type":"application/vnd.dsse+json","payload_hash":"sha256:payload","manifest_hash":"sha256:manifest"}"#,
1319 )
1320 .unwrap();
1321 assert_eq!(manifest.payload_hash.as_deref(), Some("sha256:payload"));
1322
1323 let record: McpQuarantineRecord = serde_json::from_str(
1324 r#"{"server_id":"mcp1","risk":"high","state":"quarantined","discovered_at":"2026-05-05T00:00:00Z"}"#,
1325 )
1326 .unwrap();
1327 assert_eq!(record.server_id, "mcp1");
1328
1329 let grant: SandboxGrant = serde_json::from_str(
1330 r#"{"grant_id":"grant1","runtime":"wazero","profile":"deny-default","env":{"mode":"deny-all"},"network":{"mode":"deny-all"},"declared_at":"2026-05-05T00:00:00Z"}"#,
1331 )
1332 .unwrap();
1333 assert_eq!(grant.grant_id, "grant1");
1334 }
1335
1336 #[test]
1337 fn test_boundary_status_default_is_fail_closed() {
1338 let status = BoundaryStatus::default();
1339 assert_eq!(status.status, BoundaryStatusStatus::Degraded);
1340 assert_eq!(
1341 status.receipt_signer,
1342 BoundaryStatusReceiptSigner::Unavailable
1343 );
1344 assert_eq!(
1345 status.receipt_store,
1346 BoundaryStatusReceiptStore::Unavailable
1347 );
1348
1349 let json = serde_json::to_value(status).unwrap();
1350 assert_eq!(json["status"], "degraded");
1351 assert_eq!(json["receipt_signer"], "unavailable");
1352 assert_eq!(json["receipt_store"], "unavailable");
1353 }
1354}