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 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// ── Proto-generated types (available when compiled with `--features codegen`) ──
15#[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/// Error returned by HELM API calls.
35#[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
208/// Typed client for the HELM kernel API.
209pub struct HelmClient {
210    base_url: String,
211    client: Client,
212}
213
214impl HelmClient {
215    /// Create a new client.
216    pub fn new(base_url: &str) -> Self {
217        Self::with_auth(base_url, None, None, None)
218    }
219
220    /// Create a client with optional API key, tenant, and principal headers for protected routes.
221    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    /// POST /v1/chat/completions
403    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    /// POST /api/v1/evaluate using the legacy dynamic contract.
426    #[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    /// POST /api/v1/evaluate using the canonical V5 request and response.
435    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    /// POST /api/v1/kernel/approve
471    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    /// GET /api/v1/proofgraph/sessions
491    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    /// GET /api/v1/proofgraph/sessions/{id}/receipts
510    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    /// POST /api/v1/evidence/export — returns raw bytes
532    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    /// POST /api/v1/evidence/verify
556    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    /// POST /api/v1/replay/verify
583    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    /// POST /api/v1/evidence/envelopes
610    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    /// GET /api/v1/proofgraph/receipts/{hash}
670    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    /// POST /api/v1/conformance/run
689    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    /// GET /api/v1/conformance/reports/{id}
712    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    /// GET /api/v1/conformance/negative
734    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    /// GET /api/v1/mcp/registry
763    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    /// POST /api/v1/mcp/registry
782    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    /// POST /api/v1/mcp/registry/approve
805    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    /// GET /api/v1/sandbox/grants/inspect
931    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(&params.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    /// GET /healthz
1111    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    /// GET /version
1130    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}