Skip to main content

warble_claude_code/
native_host.rs

1//! Explicit host-owned execution behind a native conversation. Descriptors are implementation
2//! claims, never attestations. The host must verify live identity and isolation before admission.
3use crate::interactive::{NativeEntryKind, NativePurpose, NativeSessionScope};
4use crate::ir::{ComponentNode, WarbleIr};
5use crate::session::{produce_session, COMPONENT_HOST_PROTOCOL, MAX_INPUT_BYTES};
6use crate::slots::SlotSupply;
7use crate::DispatchError;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use sha2::{Digest, Sha256};
11use std::collections::{BTreeMap, BTreeSet};
12
13#[derive(Deserialize)]
14#[serde(deny_unknown_fields)]
15struct Descriptor {
16    version: String,
17    protocol: String,
18    vendor: String,
19    session_id: String,
20    auth_identity: String,
21    runtime_generation: String,
22    binding: crate::interactive::NativeBinding,
23    /// Host-owned context snapshot digests, independently bound to every governed component.
24    prepared_contexts: BTreeMap<String, String>,
25    /// A format-2 direct-session host contract per admitted composed root.
26    roots: BTreeMap<String, Value>,
27}
28
29#[derive(Clone, Debug)]
30pub struct NativeHost {
31    document: Value,
32    ir: WarbleIr,
33    scope_digest: String,
34    target: String,
35    roots: BTreeMap<String, String>,
36}
37
38fn fail(message: &str) -> DispatchError {
39    DispatchError(format!("native component host: {message} (wall-hit)"))
40}
41fn digest(value: &Value) -> String {
42    format!("sha256:{:x}", Sha256::digest(value.to_string().as_bytes()))
43}
44fn opaque(value: &str) -> bool {
45    !value.is_empty()
46        && value.len() <= 256
47        && !value.chars().any(|c| c.is_control() || c.is_whitespace())
48}
49fn is_digest(value: &str) -> bool {
50    value.strip_prefix("sha256:").is_some_and(|s| {
51        s.len() == 64
52            && s.bytes()
53                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
54    })
55}
56
57pub fn has_calls(node: &ComponentNode) -> bool {
58    node.llm_calls
59        .iter()
60        .any(|step| !step.component_calls.is_empty())
61}
62
63pub(crate) fn resolution_report(node: &ComponentNode) -> crate::resolve::ResolutionReport {
64    node.required_capabilities.iter().map(|capability| crate::resolve::ResolvedCapability {
65        capability: capability.clone(), outcome: crate::targets::CapabilityOutcome::RealizeVia,
66        provided_by: crate::targets::ProvidedBy::Runtime, criticality: crate::targets::Criticality::SafetyCritical,
67        note: Some("Explicit host-owned direct-session plan; runtime identity, context and isolation must be verified before execution. Emission is not readiness.".into()),
68    }).collect()
69}
70
71impl NativeHost {
72    /// The same immutable typed snapshot that was validated and hashed during preparation.
73    /// Native emission must use this snapshot rather than reopening a mutable IR file.
74    pub fn ir(&self) -> &WarbleIr {
75        &self.ir
76    }
77    /// Prepare all admitted roots from the original bytes. No edge is removed or lowered into
78    /// a native subagent; direct-session v2 preserves the complete governed closure.
79    pub fn prepare(
80        raw_ir: &str,
81        raw_descriptor: &str,
82        target: &str,
83        purpose: NativePurpose,
84        scope: &NativeSessionScope,
85    ) -> Result<Self, DispatchError> {
86        if raw_ir.len() > MAX_INPUT_BYTES || raw_descriptor.len() > MAX_INPUT_BYTES {
87            return Err(fail("input exceeds size limit"));
88        }
89        let value = crate::session::unique_json(raw_descriptor)?;
90        let descriptor: Descriptor =
91            serde_json::from_value(value).map_err(|_| fail("invalid host descriptor"))?;
92        let raw = crate::session::unique_json(raw_ir)?;
93        let ir: WarbleIr = serde_json::from_value(raw.clone()).map_err(|_| fail("invalid IR"))?;
94        let vendor = match target {
95            "claude-code:interactive" => "claude",
96            "codex:interactive" => "codex",
97            _ => return Err(fail("requires native interactive target")),
98        };
99        if descriptor.version != "1"
100            || descriptor.protocol != COMPONENT_HOST_PROTOCOL
101            || descriptor.vendor != vendor
102            || purpose != NativePurpose::Analysis
103            || (vendor == "codex" && scope.entry.kind == NativeEntryKind::Scope)
104        {
105            return Err(fail("unsupported purpose, vendor, entry or protocol"));
106        }
107        for identity in [
108            &descriptor.session_id,
109            &descriptor.auth_identity,
110            &descriptor.runtime_generation,
111        ] {
112            if !opaque(identity) {
113                return Err(fail("missing bounded host identity"));
114            }
115        }
116        if serde_json::to_value(&scope.binding).map_err(|_| fail("invalid scope binding"))?
117            != serde_json::to_value(&descriptor.binding)
118                .map_err(|_| fail("invalid host binding"))?
119        {
120            return Err(fail("host binding differs from native scope"));
121        }
122        let entries = WarbleIr {
123            components: ir
124                .components
125                .iter()
126                .filter(|n| n.entrypoint)
127                .cloned()
128                .collect(),
129            ..ir.clone()
130        };
131        purpose.validate_profile(&entries, &scope.entry)?;
132        for node in raw["components"]
133            .as_array()
134            .ok_or_else(|| fail("missing components"))?
135        {
136            if node["entrypoint"] == true
137                && scope.entry.pinned_verb().is_none_or(|id| node["id"] == id)
138                && node
139                    .get("assets")
140                    .is_some_and(|v| !v.as_array().is_some_and(Vec::is_empty))
141            {
142                return Err(fail("native host materialization does not support assets"));
143            }
144        }
145        let selected = entries
146            .components
147            .iter()
148            .filter(|n| scope.entry.pinned_verb().is_none_or(|id| id == n.id))
149            .filter(|n| scope.entry.kind == NativeEntryKind::Agent || has_calls(n))
150            .collect::<Vec<_>>();
151        if selected.is_empty()
152            || selected
153                .iter()
154                .map(|n| n.id.as_str())
155                .collect::<BTreeSet<_>>()
156                != descriptor
157                    .roots
158                    .keys()
159                    .map(String::as_str)
160                    .collect::<BTreeSet<_>>()
161        {
162            return Err(fail(
163                "host roots must exactly match admitted composed entries",
164            ));
165        }
166        let mut roots = BTreeMap::new();
167        let mut plans = BTreeMap::new();
168        let mut contexts = BTreeSet::new();
169        for node in selected {
170            if node.id != node.verb {
171                return Err(fail("native root identity must match its advertised verb"));
172            }
173            let contract = &descriptor.roots[&node.id];
174            if contract["version"] != "2" {
175                return Err(fail("native composition requires direct-session format 2"));
176            }
177            let plan =
178                produce_session(raw_ir, &contract.to_string(), &node.id, &SlotSupply::new())?;
179            for id in plan["components"]
180                .as_object()
181                .expect("validated plan registry")
182                .keys()
183            {
184                contexts.insert(id.clone());
185            }
186            let tool = format!("warble_run_{:x}", Sha256::digest(node.id.as_bytes()));
187            // MCP tool identifiers are bounded; the full root remains in the immutable registry.
188            roots.insert(node.id.clone(), tool[..43].to_string());
189            plans.insert(node.id.clone(), plan);
190        }
191        if descriptor
192            .prepared_contexts
193            .keys()
194            .cloned()
195            .collect::<BTreeSet<_>>()
196            != contexts
197            || descriptor.prepared_contexts.values().any(|v| !is_digest(v))
198        {
199            return Err(fail(
200                "missing or invalid per-component prepared context identity",
201            ));
202        }
203        let scope_digest =
204            digest(&serde_json::to_value(scope).map_err(|_| fail("scope serialization"))?);
205        let mut document = json!({
206            "version": "1", "protocol": COMPONENT_HOST_PROTOCOL, "execution": "host_owned_steps",
207            "execution_status": "not_executed", "vendor": vendor,
208            "session_id": descriptor.session_id, "auth_identity": descriptor.auth_identity,
209            "runtime_generation": descriptor.runtime_generation, "binding": descriptor.binding,
210            "prepared_contexts": descriptor.prepared_contexts, "root_tools": roots, "plans": plans,
211            "input_ir_sha256": format!("sha256:{:x}", Sha256::digest(raw_ir.as_bytes())),
212            "scope_sha256": scope_digest,
213            "request_schema": {"type":"object", "additionalProperties":false,
214                "required":["request"], "properties":{"request":{"type":"string", "minLength":1, "maxLength":65536}}},
215        });
216        document["host_plan_sha256"] = json!(digest(&document));
217        if document.to_string().len() > MAX_INPUT_BYTES {
218            return Err(fail("native plan exceeds size limit"));
219        }
220        Ok(Self {
221            document,
222            ir,
223            scope_digest,
224            target: target.into(),
225            roots,
226        })
227    }
228
229    pub(crate) fn validate(
230        &self,
231        ir: &WarbleIr,
232        target: &str,
233        purpose: Option<NativePurpose>,
234        scope: Option<&NativeSessionScope>,
235        has_mcp: bool,
236    ) -> Result<(), DispatchError> {
237        if self.target != target
238            || purpose != Some(NativePurpose::Analysis)
239            || !has_mcp
240            || ir != &self.ir
241            || scope.map(|s| digest(&serde_json::to_value(s).expect("scope serializes")))
242                != Some(self.scope_digest.clone())
243        {
244            return Err(fail("host plan does not match native launch inputs"));
245        }
246        Ok(())
247    }
248    pub(crate) fn tool(&self, id: &str) -> Option<&str> {
249        self.roots.get(id).map(String::as_str)
250    }
251    pub(crate) fn tool_names(&self) -> Vec<&str> {
252        self.roots.values().map(String::as_str).collect()
253    }
254    pub(crate) fn document(&self) -> &Value {
255        &self.document
256    }
257    pub(crate) fn launch_value(&self) -> Value {
258        json!({"protocol": COMPONENT_HOST_PROTOCOL, "execution": "host_owned_steps",
259            "plan_path": ".warble/component-plans.json", "host_plan_sha256": self.document["host_plan_sha256"],
260            "vendor": self.document["vendor"], "auth_identity": self.document["auth_identity"],
261            "session_id": self.document["session_id"], "runtime_generation": self.document["runtime_generation"],
262            "binding": self.document["binding"], "root_tools": self.roots})
263    }
264    pub(crate) fn instructions(&self, node: &ComponentNode) -> String {
265        let tool = self.tool(&node.id).expect("governed root");
266        format!("Run the requested work through `genbi_session.{tool}` with only `request`, the user's request as text. This is admission to this fixed root; the host owns every step, child, context, tool and artifact. Do not execute these steps locally, supply step/child identity, run SQL, or reconstruct a result. Report only the correlated host result in readable prose. A refusal is not a successful artifact. The terminal's existing conversation is retained, but governed steps execute in fresh host-managed contexts using the same approved vendor/account.")
267    }
268    pub(crate) fn claude_wrapper(&self, node: &ComponentNode) -> String {
269        // Serialize scalar values as JSON (valid YAML) to prevent frontmatter injection.
270        let description = serde_json::to_string(node.description.as_deref().unwrap_or(&node.verb))
271            .expect("string");
272        format!(
273            "---\nname: {}\ndescription: {}\ntools: mcp__genbi_session__{}\n---\n\n{}\n",
274            node.id,
275            description,
276            self.tool(&node.id).expect("governed root"),
277            self.instructions(node)
278        )
279    }
280}