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