Skip to main content

chio_kernel/kernel/evaluation/
evaluation_entry.rs

1use super::*;
2
3impl ChioKernel {
4    /// Open a new logical session for an agent and bind any capabilities that
5    /// were issued during setup to that session.
6    ///
7    /// Design note: unlike the hosted-tool and nested-flow dispatch
8    /// paths, this surface deliberately does NOT call
9    /// [`Self::admit_capability_budget`] after the pre-admit verifier
10    /// pass. Read-only resource/prompt operations
11    /// (`subscribe_resource`, `read_resource`, `get_prompt`,
12    /// `complete`) are not economic actions: they neither consume the
13    /// caller's per-tool invocation budget nor reserve the caller's
14    /// share against its parent in the sibling-sum registry.
15    /// PROTOCOL.md section "Single-entry capability verifier" already
16    /// authorises this split as MAY: the MUST is that every surface
17    /// traverses `verify_capability_full` exactly once (which this
18    /// helper does); the authoritative admit phase is reserved for
19    /// surfaces that actually execute a side-effecting action against
20    /// the budget.
21    pub(crate) fn validate_non_tool_capability(
22        &self,
23        capability: &CapabilityToken,
24        agent_id: &str,
25    ) -> Result<(), KernelError> {
26        // Emergency kill switch: resource/prompt operations that go
27        // through this helper must also deny-fast so the kill switch applies
28        // to every capability-backed surface, not just tool calls.
29        if self.is_emergency_stopped() {
30            return Err(KernelError::GuardDenied(
31                EMERGENCY_STOP_DENY_REASON.to_string(),
32            ));
33        }
34        // RSS soft ceiling: shed new admissions before the OS OOM-kills the
35        // mediator. The tool-call fast path sheds here; a
36        // resource/prompt/completion or any other non-tool capability-backed
37        // operation that flows through this helper must shed on the SAME soft
38        // ceiling, or a large read_resource / prompt completion could still
39        // allocate and execute under RSS pressure while tool calls are being shed.
40        // Fail-closed: return Overloaded so the soft ceiling sheds ALL new
41        // admissions uniformly and the tower load-shed edge surfaces backpressure.
42        if self.is_rss_shedding() {
43            return Err(KernelError::Overloaded {
44                resource: crate::OverloadResource::Allocation,
45            });
46        }
47        let now_unix_ms = current_unix_timestamp_ms();
48        let now = now_unix_ms / 1000;
49        self.verify_capability_full_pre_admit(capability, None, now)
50            .map_err(KernelError::GuardDenied)?;
51        self.check_revocation(capability)?;
52        self.validate_delegation_admission(capability)?;
53        check_subject_binding(capability, agent_id)?;
54        Ok(())
55    }
56
57    /// Evaluate a tool call request.
58    ///
59    /// This is the kernel's main entry point. It performs the full validation
60    /// pipeline:
61    ///
62    /// 1. Verify capability signature against known CA public keys.
63    /// 2. Check time bounds (not expired, not-before satisfied).
64    /// 3. Check revocation status of the capability and its delegation chain.
65    /// 4. Verify the requested tool is within the capability's scope.
66    /// 5. Check and decrement invocation budget.
67    /// 6. Run all registered guards.
68    /// 7. If all pass: forward to tool server, sign allow receipt.
69    /// 8. If any fail: sign deny receipt.
70    ///
71    /// Every call -- whether allowed or denied -- produces exactly one signed
72    /// receipt.
73    pub async fn evaluate_tool_call(
74        &self,
75        request: &ToolCallRequest,
76    ) -> Result<ToolCallResponse, KernelError> {
77        self.evaluate_tool_call_async_with_session_context(
78            request,
79            None,
80            None,
81            None,
82            PreflightHoldDisposition::ReverseForRetry,
83        )
84        .await
85    }
86
87    pub async fn evaluate_tool_call_with_metadata(
88        &self,
89        request: &ToolCallRequest,
90        extra_metadata: Option<serde_json::Value>,
91    ) -> Result<ToolCallResponse, KernelError> {
92        self.evaluate_tool_call_async_with_session_context(
93            request,
94            None,
95            extra_metadata,
96            None,
97            PreflightHoldDisposition::ReverseForRetry,
98        )
99        .await
100    }
101
102    pub fn sign_planned_deny_response(
103        &self,
104        request: &ToolCallRequest,
105        reason: &str,
106        extra_metadata: Option<serde_json::Value>,
107    ) -> Result<ToolCallResponse, KernelError> {
108        self.build_deny_response_with_metadata(
109            request,
110            reason,
111            current_unix_timestamp(),
112            None,
113            extra_metadata,
114        )
115    }
116
117    /// Plan-level evaluation.
118    ///
119    /// Takes an ordered list of planned tool calls under a single
120    /// capability token and evaluates every step INDEPENDENTLY against
121    /// the pre-invocation portion of the evaluation pipeline: capability
122    /// signature / time-bound / revocation / subject binding, the
123    /// request-matching pass (scope + constraints + model constraint),
124    /// and the registered guard pipeline. No tool-server dispatch, no
125    /// budget mutation, no receipt emission, and no cross-step state
126    /// propagation take place: this is a stateless pre-flight check.
127    ///
128    /// Dependencies between planned steps are advisory metadata only in
129    /// v1: the kernel does not topologically sort the graph, refuse on
130    /// cycles, or short-circuit downstream steps when an earlier step
131    /// denies. Callers are expected to make that decision themselves
132    /// once they have the per-step verdict list.
133    ///
134    /// Guards that require post-invocation output (response-shaping,
135    /// streaming sanitizers, etc.) are inherently skipped because no
136    /// tool output exists; every registered guard is
137    /// invoked against the synthesised pre-flight request, matching the
138    /// set of guards that run in `evaluate_tool_call` before dispatch.
139    ///
140    /// Plan evaluation does not emit receipts. The kernel emits structured
141    /// trace spans for the plan and every per-step verdict so operators can
142    /// correlate plan evaluations with subsequent tool-call receipts.
143    pub async fn evaluate_plan(
144        &self,
145        req: chio_core_types::PlanEvaluationRequest,
146    ) -> chio_core_types::PlanEvaluationResponse {
147        self.evaluate_plan_blocking(&req)
148    }
149
150    /// Synchronous variant of [`Self::evaluate_plan`] for substrate
151    /// adapters that do not run on an async runtime.
152    ///
153    /// Plan evaluation never touches the network, so the async method
154    /// is a thin wrapper over this blocking implementation.
155    pub fn evaluate_plan_blocking(
156        &self,
157        req: &chio_core_types::PlanEvaluationRequest,
158    ) -> chio_core_types::PlanEvaluationResponse {
159        use chio_core_types::{PlanEvaluationResponse, PlanVerdict, StepVerdict, StepVerdictKind};
160
161        debug!(
162            plan_id = %req.plan_id,
163            planner_capability_id = %req.planner_capability_id,
164            step_count = req.steps.len(),
165            "evaluating plan"
166        );
167
168        let mut step_verdicts = Vec::with_capacity(req.steps.len());
169
170        // Reject capability-id mismatches once, up front: every step is
171        // evaluated under the same token so a mismatch is fatal for the
172        // whole plan. Fail-closed: every step is flagged denied.
173        if req.planner_capability.id != req.planner_capability_id {
174            let reason = format!(
175                "planner_capability_id {} does not match embedded token id {}",
176                req.planner_capability_id, req.planner_capability.id
177            );
178            for (index, _) in req.steps.iter().enumerate() {
179                step_verdicts.push(StepVerdict {
180                    step_index: index,
181                    verdict: StepVerdictKind::Denied,
182                    reason: Some(reason.clone()),
183                    guard: None,
184                });
185            }
186            let plan_verdict = if step_verdicts.is_empty() {
187                PlanVerdict::FullyDenied
188            } else {
189                PlanEvaluationResponse::aggregate(&step_verdicts)
190            };
191            return PlanEvaluationResponse {
192                plan_id: req.plan_id.clone(),
193                plan_verdict,
194                step_verdicts,
195            };
196        }
197
198        // Emergency stop applies to plan evaluation too: a stopped kernel
199        // must not leak any information about what the plan might allow.
200        if self.is_emergency_stopped() {
201            warn!(
202                plan_id = %req.plan_id,
203                "emergency stop active -- denying evaluate_plan"
204            );
205            for (index, _) in req.steps.iter().enumerate() {
206                step_verdicts.push(StepVerdict {
207                    step_index: index,
208                    verdict: StepVerdictKind::Denied,
209                    reason: Some(EMERGENCY_STOP_DENY_REASON.to_string()),
210                    guard: None,
211                });
212            }
213            let plan_verdict = if step_verdicts.is_empty() {
214                PlanVerdict::FullyDenied
215            } else {
216                PlanEvaluationResponse::aggregate(&step_verdicts)
217            };
218            return PlanEvaluationResponse {
219                plan_id: req.plan_id.clone(),
220                plan_verdict,
221                step_verdicts,
222            };
223        }
224
225        for (index, step) in req.steps.iter().enumerate() {
226            let verdict = self.evaluate_plan_step(req, step, index);
227            step_verdicts.push(verdict);
228        }
229
230        let plan_verdict = PlanEvaluationResponse::aggregate(&step_verdicts);
231
232        debug!(
233            plan_id = %req.plan_id,
234            plan_verdict = ?plan_verdict,
235            "plan evaluation complete"
236        );
237
238        PlanEvaluationResponse {
239            plan_id: req.plan_id.clone(),
240            plan_verdict,
241            step_verdicts,
242        }
243    }
244
245    fn evaluate_plan_step(
246        &self,
247        req: &chio_core_types::PlanEvaluationRequest,
248        step: &chio_core_types::PlannedToolCall,
249        index: usize,
250    ) -> chio_core_types::StepVerdict {
251        use chio_core_types::{StepVerdict, StepVerdictKind};
252
253        let now = current_unix_timestamp();
254        let cap = &req.planner_capability;
255
256        // Design note: plan-evaluation is a PREVIEW path -- it answers
257        // "if this plan ran, would each step be allowed?" without
258        // dispatching the underlying tool calls. Calling
259        // [`Self::admit_capability_budget`] here would consume
260        // sibling-sum budget for plans that may never execute, which
261        // is the opposite of preview semantics. The pre-admit verifier
262        // pass below covers the spec MUST (every surface traverses
263        // `verify_capability_full` exactly once); the authoritative
264        // admit phase is reserved for the actual hosted-tool /
265        // nested-flow dispatch paths in
266        // `evaluate_tool_call_*_with_session_context`.
267        //
268        // Capability-wide checks repeat per-step so a failure here is
269        // still reflected in every step's verdict, keeping the per-step
270        // output self-contained.
271        if let Err(reason) = self.verify_capability_full_pre_admit(cap, None, now) {
272            return StepVerdict {
273                step_index: index,
274                verdict: StepVerdictKind::Denied,
275                reason: Some(format!("capability verification failed: {reason}")),
276                guard: None,
277            };
278        }
279        if let Err(error) = check_time_bounds(cap, now) {
280            return StepVerdict {
281                step_index: index,
282                verdict: StepVerdictKind::Denied,
283                reason: Some(error.to_string()),
284                guard: None,
285            };
286        }
287        if let Err(error) = self.check_revocation(cap) {
288            return StepVerdict {
289                step_index: index,
290                verdict: StepVerdictKind::Denied,
291                reason: Some(error.to_string()),
292                guard: None,
293            };
294        }
295        if let Err(error) = check_subject_binding(cap, &req.agent_id) {
296            return StepVerdict {
297                step_index: index,
298                verdict: StepVerdictKind::Denied,
299                reason: Some(error.to_string()),
300                guard: None,
301            };
302        }
303
304        // Synthesise a ToolCallRequest so the same request-matching and
305        // guard machinery applies to plan steps as to runtime calls. No
306        // DPoP / governed-intent / approval-token shape is carried: plan
307        // evaluation is a pre-flight check and is not a substitute for
308        // those runtime-only proofs.
309        let synthesised = ToolCallRequest {
310            request_id: step.request_id.clone(),
311            capability: cap.clone(),
312            tool_name: step.tool_name.clone(),
313            server_id: step.server_id.clone(),
314            agent_id: req.agent_id.clone(),
315            arguments: step.parameters.clone(),
316            dpop_proof: None,
317            execution_nonce: None,
318            governed_intent: None,
319            approval_token: None,
320            approval_tokens: Vec::new(),
321            threshold_approval_proposal: None,
322            supplemental_authorization: None,
323            model_metadata: step.model_metadata.clone(),
324            federated_origin_kernel_id: None,
325        };
326
327        let matching_grants = match resolve_required_matching_grants(
328            cap,
329            &synthesised.tool_name,
330            &synthesised.server_id,
331            &synthesised.arguments,
332            synthesised.model_metadata.as_ref(),
333        ) {
334            Ok(grants) => grants,
335            Err(error) => {
336                return StepVerdict {
337                    step_index: index,
338                    verdict: StepVerdictKind::Denied,
339                    reason: Some(error.to_string()),
340                    guard: None,
341                };
342            }
343        };
344
345        let matched_grant_index = matching_grants
346            .first()
347            .map(|matching| matching.index)
348            .unwrap_or(0);
349
350        // Fail-closed: any guard error reads as a denial so the caller still
351        // sees a per-step reason string.
352        if let Err(error) =
353            self.run_guards(&synthesised, &cap.scope, None, Some(matched_grant_index))
354        {
355            // Attempt to extract the offending guard name from the
356            // canonical `guard "<name>" denied the request` format
357            // emitted by run_guards.
358            let message = error.error.to_string();
359            let guard = extract_guard_name(&message);
360            return StepVerdict {
361                step_index: index,
362                verdict: StepVerdictKind::Denied,
363                reason: Some(message),
364                guard,
365            };
366        }
367
368        StepVerdict {
369            step_index: index,
370            verdict: StepVerdictKind::Allowed,
371            reason: None,
372            guard: None,
373        }
374    }
375}