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