Skip to main content

helm_sdk/
lib.rs

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