1use alloc::string::{String, ToString};
35
36use chio_core_types::capability::{
37 crypto_floor::CapabilityCryptoFloor, features::CapabilityNegotiation, scope::ChioScope,
38 token::CapabilityToken,
39};
40use chio_core_types::crypto::PublicKey;
41
42use crate::budget_split::{BudgetRegistry, NoopBudgetRegistry};
43use crate::capability_verify::{
44 admit_delegated_budget, verify_capability_with_floor, CapabilityError, TrustRootResolver,
45 VerifiedCapability,
46};
47use crate::clock::Clock;
48use crate::guard::{Guard, GuardContext, PortableToolCallRequest};
49use crate::normalized::{NormalizationError, NormalizedEvaluationVerdict};
50use crate::scope::resolve_matching_grants;
51use crate::Verdict;
52
53pub struct EvaluateInput<'a> {
57 pub request: &'a PortableToolCallRequest,
59 pub capability: &'a CapabilityToken,
61 pub trusted_issuers: &'a [PublicKey],
63 pub clock: &'a dyn Clock,
65 pub guards: &'a [&'a dyn Guard],
67 pub session_filesystem_roots: Option<&'a [String]>,
70}
71
72#[derive(Debug, Clone)]
78pub struct EvaluationVerdict {
79 pub verdict: Verdict,
82 pub reason: Option<String>,
84 pub matched_grant_index: Option<usize>,
86 pub verified: Option<VerifiedCapability>,
89}
90
91impl EvaluationVerdict {
92 #[must_use]
94 pub fn is_allow(&self) -> bool {
95 self.verdict == Verdict::Allow
96 }
97
98 #[must_use]
100 pub fn is_deny(&self) -> bool {
101 self.verdict == Verdict::Deny
102 }
103
104 pub fn normalized(
106 &self,
107 request: &PortableToolCallRequest,
108 ) -> Result<NormalizedEvaluationVerdict, NormalizationError> {
109 NormalizedEvaluationVerdict::try_from_evaluation(request, self)
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum KernelCoreError {
121 InvalidCapability(CapabilityError),
123 SubjectMismatch { expected: String, actual: String },
125 OutOfScope { tool: String, server: String },
127 ConstraintError { reason: String },
129 UnsupportedCapabilityFeature { feature: String },
131 GuardError { guard: String, reason: String },
133 GuardDenied { guard: String },
135}
136
137impl KernelCoreError {
138 #[must_use]
140 pub fn deny_reason(&self) -> String {
141 match self {
142 KernelCoreError::InvalidCapability(error) => match error {
143 CapabilityError::UntrustedIssuer => {
144 "capability issuer is not a trusted CA".to_string()
145 }
146 CapabilityError::InvalidSignature => "capability signature is invalid".to_string(),
147 CapabilityError::CryptoFloorRejected(msg) => {
148 let mut out = String::from("capability rejected by crypto floor: ");
149 out.push_str(msg);
150 out
151 }
152 CapabilityError::NotYetValid => "capability not yet valid".to_string(),
153 CapabilityError::Expired => "capability has expired".to_string(),
154 CapabilityError::AttenuationViolation(msg) => {
155 let mut out = String::from("capability rejected by chain binding: ");
156 out.push_str(msg);
157 out
158 }
159 CapabilityError::BudgetSplitRejected(err) => {
160 let mut out = String::from("capability budget split rejected: ");
161 let formatted = alloc::format!("{err}");
163 out.push_str(&formatted);
164 out
165 }
166 CapabilityError::Internal(msg) => {
167 let mut out = String::from("capability verification failed: ");
168 out.push_str(msg);
169 out
170 }
171 },
172 KernelCoreError::SubjectMismatch { expected, actual } => {
173 let mut out = String::from("request agent ");
174 out.push_str(actual);
175 out.push_str(" does not match capability subject ");
176 out.push_str(expected);
177 out
178 }
179 KernelCoreError::OutOfScope { tool, server } => {
180 let mut out = String::from("requested tool ");
181 out.push_str(tool);
182 out.push_str(" on server ");
183 out.push_str(server);
184 out.push_str(" is not in capability scope");
185 out
186 }
187 KernelCoreError::ConstraintError { reason } => {
188 let mut out = String::from("constraint evaluation failed: ");
189 out.push_str(reason);
190 out
191 }
192 KernelCoreError::UnsupportedCapabilityFeature { feature } => {
193 let mut out = String::from("capability feature unsupported on this runtime: ");
194 out.push_str(feature);
195 out
196 }
197 KernelCoreError::GuardError { guard, reason } => {
198 let mut out = String::from("guard \"");
199 out.push_str(guard);
200 out.push_str("\" error (fail-closed): ");
201 out.push_str(reason);
202 out
203 }
204 KernelCoreError::GuardDenied { guard } => {
205 let mut out = String::from("guard \"");
206 out.push_str(guard);
207 out.push_str("\" denied the request");
208 out
209 }
210 }
211 }
212}
213
214fn out_of_scope_error(request: &PortableToolCallRequest) -> KernelCoreError {
215 KernelCoreError::OutOfScope {
216 tool: request.tool_name.clone(),
217 server: request.server_id.clone(),
218 }
219}
220
221fn resolve_matched_grant_index(
222 scope: &ChioScope,
223 request: &PortableToolCallRequest,
224) -> Result<usize, KernelCoreError> {
225 let matches = match resolve_matching_grants(
226 scope,
227 &request.tool_name,
228 &request.server_id,
229 &request.arguments,
230 ) {
231 Ok(matches) => matches,
232 Err(crate::ScopeMatchError::OutOfScope) => return Err(out_of_scope_error(request)),
233 Err(crate::ScopeMatchError::ConstraintError(reason)) => {
234 return Err(KernelCoreError::ConstraintError { reason });
235 }
236 };
237
238 matches
239 .first()
240 .map(|matched| matched.index)
241 .ok_or_else(|| out_of_scope_error(request))
242}
243
244pub fn evaluate(input: EvaluateInput<'_>) -> EvaluationVerdict {
259 evaluate_with_crypto_floor(input, CapabilityCryptoFloor::AllowClassical)
260}
261
262pub fn evaluate_with_crypto_floor(
277 input: EvaluateInput<'_>,
278 crypto_floor: CapabilityCryptoFloor,
279) -> EvaluationVerdict {
280 let mut budgets = NoopBudgetRegistry;
281 evaluate_with_crypto_floor_and_budgets(input, crypto_floor, &mut budgets)
282}
283
284pub fn evaluate_with_crypto_floor_and_budgets(
289 input: EvaluateInput<'_>,
290 crypto_floor: CapabilityCryptoFloor,
291 budgets: &mut dyn BudgetRegistry,
292) -> EvaluationVerdict {
293 if input.capability.attenuation_proof.is_some() {
295 let core_err = KernelCoreError::InvalidCapability(CapabilityError::AttenuationViolation(
296 "chain-binding requires a trust-root resolver on the evaluate path".to_string(),
297 ));
298 return deny(core_err, None, None);
299 }
300
301 let mut verify_only_budgets = NoopBudgetRegistry;
302 let verified = match verify_capability_with_floor(
303 input.capability,
304 input.trusted_issuers,
305 input.clock,
306 crypto_floor,
307 &mut verify_only_budgets,
308 ) {
309 Ok(verified) => verified,
310 Err(error) => {
311 let core_err = KernelCoreError::InvalidCapability(error);
312 return deny(core_err, None, None);
313 }
314 };
315
316 finish_verified_evaluation(input, verified, budgets)
317}
318
319pub fn evaluate_with_full_floor(
333 input: EvaluateInput<'_>,
334 crypto_floor: CapabilityCryptoFloor,
335 peer: &CapabilityNegotiation,
336 trust_root: &dyn TrustRootResolver,
337 budgets: &mut dyn BudgetRegistry,
338) -> EvaluationVerdict {
339 evaluate_with_full_floor_and_root(input, crypto_floor, peer, None, trust_root, budgets)
340}
341
342pub fn evaluate_with_full_floor_and_root(
344 input: EvaluateInput<'_>,
345 crypto_floor: CapabilityCryptoFloor,
346 peer: &CapabilityNegotiation,
347 direct_root: Option<&CapabilityToken>,
348 trust_root: &dyn TrustRootResolver,
349 budgets: &mut dyn BudgetRegistry,
350) -> EvaluationVerdict {
351 let mut verify_only_budgets = NoopBudgetRegistry;
357 let verified = match crate::capability_verify::verify_capability_full_with_root(
358 input.capability,
359 input.trusted_issuers,
360 input.clock,
361 crypto_floor,
362 crate::capability_verify::CapabilityFeatureContext { peer, direct_root },
363 trust_root,
364 &mut verify_only_budgets,
365 ) {
366 Ok(verified) => verified,
367 Err(error) => {
368 let core_err = KernelCoreError::InvalidCapability(error);
369 return deny(core_err, None, None);
370 }
371 };
372 if input.capability.aggregate_invocation_budget.is_some() {
373 return deny(
374 KernelCoreError::UnsupportedCapabilityFeature {
375 feature: "aggregate invocation enforcement".to_string(),
376 },
377 None,
378 Some(verified),
379 );
380 }
381 if input.capability.scope.has_cumulative_approval() {
382 return deny(
383 KernelCoreError::UnsupportedCapabilityFeature {
384 feature: "cumulative approval enforcement".to_string(),
385 },
386 None,
387 Some(verified),
388 );
389 }
390
391 finish_verified_evaluation(input, verified, budgets)
392}
393
394fn finish_verified_evaluation(
395 input: EvaluateInput<'_>,
396 verified: VerifiedCapability,
397 budgets: &mut dyn BudgetRegistry,
398) -> EvaluationVerdict {
399 if verified.subject_hex != input.request.agent_id {
401 let core_err = KernelCoreError::SubjectMismatch {
402 expected: verified.subject_hex.clone(),
403 actual: input.request.agent_id.clone(),
404 };
405 return deny(core_err, None, Some(verified));
406 }
407
408 let matched_grant_index = match resolve_matched_grant_index(&verified.scope, input.request) {
410 Ok(index) => index,
411 Err(error) => return deny(error, None, Some(verified)),
412 };
413
414 let ctx = GuardContext {
416 request: input.request,
417 scope: &verified.scope,
418 agent_id: &input.request.agent_id,
419 server_id: &input.request.server_id,
420 session_filesystem_roots: input.session_filesystem_roots,
421 matched_grant_index: Some(matched_grant_index),
422 };
423
424 for guard in input.guards {
425 match guard.evaluate(&ctx) {
426 Ok(Verdict::Allow) => {}
427 Ok(Verdict::Deny) | Ok(Verdict::PendingApproval) => {
428 let core_err = KernelCoreError::GuardDenied {
432 guard: guard.name().to_string(),
433 };
434 return deny(core_err, Some(matched_grant_index), Some(verified));
435 }
436 Err(error) => {
437 let core_err = KernelCoreError::GuardError {
438 guard: guard.name().to_string(),
439 reason: error.deny_reason(),
440 };
441 return deny(core_err, Some(matched_grant_index), Some(verified));
442 }
443 }
444 }
445
446 if let Err(error) = admit_delegated_budget(input.capability, budgets) {
449 let core_err = KernelCoreError::InvalidCapability(error);
450 return deny(core_err, Some(matched_grant_index), Some(verified));
451 }
452
453 EvaluationVerdict {
454 verdict: Verdict::Allow,
455 reason: None,
456 matched_grant_index: Some(matched_grant_index),
457 verified: Some(verified),
458 }
459}
460
461fn deny(
462 error: KernelCoreError,
463 matched_grant_index: Option<usize>,
464 verified: Option<VerifiedCapability>,
465) -> EvaluationVerdict {
466 EvaluationVerdict {
467 verdict: Verdict::Deny,
468 reason: Some(error.deny_reason()),
469 matched_grant_index,
470 verified,
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::{BudgetRegistry, InMemoryBudgetRegistry, MAX_BUDGET_SHARE_BPS};
478 use alloc::vec;
479 use chio_core_types::capability::{
480 aggregate_invocation::{AggregateInvocationBudget, AggregateInvocationScope},
481 attenuation::{DelegationLink, DelegationLinkBody},
482 features::AGGREGATE_INVOCATION_BUDGET,
483 scope::{ChioScope, Operation, ToolGrant},
484 token::{CapabilityToken, CapabilityTokenBody},
485 };
486 use chio_core_types::crypto::Keypair;
487
488 fn grant(server_id: &str, tool_name: &str) -> ToolGrant {
489 ToolGrant {
490 server_id: server_id.to_string(),
491 tool_name: tool_name.to_string(),
492 operations: vec![Operation::Invoke],
493 constraints: vec![],
494 max_invocations: None,
495 max_cost_per_invocation: None,
496 max_total_cost: None,
497 dpop_required: None,
498 }
499 }
500
501 fn request() -> PortableToolCallRequest {
502 PortableToolCallRequest {
503 request_id: "req-1".to_string(),
504 tool_name: "echo".to_string(),
505 server_id: "srv-a".to_string(),
506 agent_id: "agent-1".to_string(),
507 arguments: serde_json::json!({"msg":"hello"}),
508 }
509 }
510
511 fn delegated_capability(
512 issuer: &Keypair,
513 subject: &Keypair,
514 parent_capability_id: &str,
515 ) -> CapabilityToken {
516 let parent_link = match DelegationLink::sign(
517 DelegationLinkBody {
518 capability_id: parent_capability_id.to_string(),
519 delegator: issuer.public_key(),
520 delegatee: issuer.public_key(),
521 attenuations: vec![],
522 timestamp: 100,
523 scope_hash: None,
524 aggregate_budget: None,
525 cumulative_approval: None,
526 },
527 issuer,
528 ) {
529 Ok(link) => link,
530 Err(error) => panic!("failed to sign parent delegation link: {error:?}"),
531 };
532
533 match CapabilityToken::sign(
534 CapabilityTokenBody {
535 id: "child-capability".to_string(),
536 issuer: issuer.public_key(),
537 subject: subject.public_key(),
538 scope: ChioScope {
539 grants: vec![grant("srv-a", "echo")],
540 resource_grants: vec![],
541 prompt_grants: vec![],
542 },
543 issued_at: 100,
544 expires_at: 200,
545 delegation_chain: vec![parent_link],
546 aggregate_invocation_budget: None,
547 },
548 issuer,
549 ) {
550 Ok(token) => token,
551 Err(error) => panic!("failed to sign delegated capability: {error:?}"),
552 }
553 }
554
555 #[test]
556 fn resolve_matched_grant_index_uses_scope_specificity_order() {
557 let scope = ChioScope {
558 grants: vec![grant("*", "*"), grant("srv-a", "echo")],
559 resource_grants: vec![],
560 prompt_grants: vec![],
561 };
562
563 let matched_index = resolve_matched_grant_index(&scope, &request());
564
565 assert_eq!(matched_index, Ok(1));
566 }
567
568 #[test]
569 fn resolve_matched_grant_index_maps_missing_grant_to_request_identity() {
570 let scope = ChioScope {
571 grants: vec![grant("srv-b", "echo")],
572 resource_grants: vec![],
573 prompt_grants: vec![],
574 };
575
576 let error = resolve_matched_grant_index(&scope, &request());
577
578 assert_eq!(
579 error,
580 Err(KernelCoreError::OutOfScope {
581 tool: "echo".to_string(),
582 server: "srv-a".to_string(),
583 })
584 );
585 }
586
587 #[test]
588 fn budget_admission_waits_until_subject_scope_and_guards_allow() {
589 let issuer = Keypair::generate();
590 let subject = Keypair::generate();
591 let wrong_agent = Keypair::generate();
592 let parent_capability_id = "parent-capability";
593 let capability = delegated_capability(&issuer, &subject, parent_capability_id);
594 let mut request = request();
595 request.agent_id = wrong_agent.public_key().to_hex();
596 let trusted = [issuer.public_key()];
597 let clock = crate::FixedClock::new(150);
598 let guards: [&dyn Guard; 0] = [];
599 let mut budgets = InMemoryBudgetRegistry::new();
600 if let Err(error) =
601 budgets.register_parent(parent_capability_id.to_string(), MAX_BUDGET_SHARE_BPS)
602 {
603 panic!("failed to register parent budget split: {error:?}");
604 }
605
606 let verdict = evaluate_with_crypto_floor_and_budgets(
607 EvaluateInput {
608 request: &request,
609 capability: &capability,
610 trusted_issuers: &trusted,
611 clock: &clock,
612 guards: &guards,
613 session_filesystem_roots: None,
614 },
615 CapabilityCryptoFloor::AllowClassical,
616 &mut budgets,
617 );
618
619 assert!(verdict.is_deny());
620 let split = match budgets.split(parent_capability_id) {
621 Some(split) => split,
622 None => panic!("registered parent budget split was missing"),
623 };
624 assert_eq!(split.current_total_child_bps(), 0);
625 assert!(split.children.is_empty());
626 }
627
628 #[test]
629 fn full_floor_budget_admission_waits_until_subject_scope_and_guards_allow() {
630 let issuer = Keypair::generate();
631 let subject = Keypair::generate();
632 let wrong_agent = Keypair::generate();
633 let parent_capability_id = "parent-capability-full";
634 let capability = delegated_capability(&issuer, &subject, parent_capability_id);
635 let mut request = request();
636 request.agent_id = wrong_agent.public_key().to_hex();
637 let trusted = [issuer.public_key()];
638 let clock = crate::FixedClock::new(150);
639 let guards: [&dyn Guard; 0] = [];
640 let peer = CapabilityNegotiation::t1_default();
641 let trust_roots = |_issuer: &chio_core_types::crypto::PublicKey| None;
642 let mut budgets = InMemoryBudgetRegistry::new();
643 if let Err(error) =
644 budgets.register_parent(parent_capability_id.to_string(), MAX_BUDGET_SHARE_BPS)
645 {
646 panic!("failed to register parent budget split: {error:?}");
647 }
648
649 let verdict = evaluate_with_full_floor(
650 EvaluateInput {
651 request: &request,
652 capability: &capability,
653 trusted_issuers: &trusted,
654 clock: &clock,
655 guards: &guards,
656 session_filesystem_roots: None,
657 },
658 CapabilityCryptoFloor::AllowClassical,
659 &peer,
660 &trust_roots,
661 &mut budgets,
662 );
663
664 assert!(verdict.is_deny());
665 let split = match budgets.split(parent_capability_id) {
666 Some(split) => split,
667 None => panic!("registered parent budget split was missing"),
668 };
669 assert_eq!(split.current_total_child_bps(), 0);
670 assert!(split.children.is_empty());
671 }
672
673 #[test]
674 fn full_floor_denies_aggregate_budget_until_enforcement_is_available() {
675 let issuer = Keypair::generate();
676 let subject = Keypair::generate();
677 let capability = match CapabilityToken::sign(
678 CapabilityTokenBody {
679 id: "aggregate-not-enforced".to_string(),
680 issuer: issuer.public_key(),
681 subject: subject.public_key(),
682 scope: ChioScope {
683 grants: vec![grant("srv-a", "echo")],
684 ..ChioScope::default()
685 },
686 issued_at: 100,
687 expires_at: 200,
688 delegation_chain: vec![],
689 aggregate_invocation_budget: Some(AggregateInvocationBudget {
690 scope: AggregateInvocationScope::Capability,
691 max_invocations: 1,
692 root_binding: None,
693 }),
694 },
695 &issuer,
696 ) {
697 Ok(capability) => capability,
698 Err(error) => panic!("failed to sign aggregate capability: {error}"),
699 };
700 let mut request = request();
701 request.agent_id = subject.public_key().to_hex();
702 let trusted = [issuer.public_key()];
703 let clock = crate::FixedClock::new(150);
704 let guards: [&dyn Guard; 0] = [];
705 let mut peer = CapabilityNegotiation::v1_default();
706 peer.features
707 .insert(AGGREGATE_INVOCATION_BUDGET.to_string(), true);
708 let trust_roots = |_issuer: &chio_core_types::crypto::PublicKey| None;
709 let mut budgets = InMemoryBudgetRegistry::new();
710
711 let verdict = evaluate_with_full_floor(
712 EvaluateInput {
713 request: &request,
714 capability: &capability,
715 trusted_issuers: &trusted,
716 clock: &clock,
717 guards: &guards,
718 session_filesystem_roots: None,
719 },
720 CapabilityCryptoFloor::AllowClassical,
721 &peer,
722 &trust_roots,
723 &mut budgets,
724 );
725
726 assert!(verdict.is_deny());
727 assert!(verdict.reason.as_deref().is_some_and(|reason| reason
728 .contains("unsupported on this runtime: aggregate invocation enforcement")));
729 }
730}