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