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}