1use std::fs;
2use std::io::{Read, Write};
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use chio_core::canonical::canonical_json_bytes;
8use chio_core::capability::{
9 attenuation::{
10 scope_hash, validate_attenuation, validate_delegation_chain, Attenuation, DelegationLink,
11 DelegationLinkBody,
12 },
13 governance::GovernedTransactionIntent,
14 scope::{ChioScope, Constraint, Operation, ToolGrant},
15 token::{CapabilityToken, CapabilityTokenBody},
16};
17use chio_core::crypto::Keypair;
18use chio_core::message::{AgentMessage, KernelMessage, ToolCallError, ToolCallResult};
19use chio_core::receipt::{
20 body::ChioReceipt, body::ChioReceiptBody, decision::Decision, decision::ToolCallAction,
21 governance::GovernedTransactionReceiptMetadata, kinds::BoundaryClass, kinds::ReceiptKind,
22 kinds::RedactionMode, kinds::ToolOrigin, metadata::GuardEvidence,
23};
24use chio_kernel::dpop::{verify_dpop_proof, DpopConfig, DpopNonceStore, DpopProof, DpopProofBody};
25use chio_kernel::transport::{read_frame, write_frame, TransportError};
26use reqwest::blocking::Client;
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum NativeDriver {
32 Artifact,
33 Stdio,
34 Http,
35}
36
37impl NativeDriver {
38 pub fn label(self) -> &'static str {
39 match self {
40 Self::Artifact => "artifact",
41 Self::Stdio => "stdio",
42 Self::Http => "http",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum NativeScenarioCategory {
50 CapabilityValidation,
51 DelegationAttenuation,
52 ReceiptIntegrity,
53 RevocationPropagation,
54 DpopVerification,
55 GovernedTransactionEnforcement,
56}
57
58impl NativeScenarioCategory {
59 pub fn heading(self) -> &'static str {
60 match self {
61 Self::CapabilityValidation => "Capability Validation",
62 Self::DelegationAttenuation => "Delegation Attenuation",
63 Self::ReceiptIntegrity => "Receipt Integrity",
64 Self::RevocationPropagation => "Revocation Propagation",
65 Self::DpopVerification => "DPoP Verification",
66 Self::GovernedTransactionEnforcement => "Governed Transaction Enforcement",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum NativeAssertionKind {
74 CapabilitySignatureValid,
75 DelegationChainValid,
76 DelegationAttenuatesParent,
77 ReceiptSignatureValid,
78 ReceiptTamperRejected,
79 DpopProofValid,
80 TerminalStatus,
81 ToolErrorCode,
82 ResponseReceiptSignatureValid,
83 GovernedReceiptPresent,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct NativeAssertionSpec {
89 pub name: String,
90 pub kind: NativeAssertionKind,
91 #[serde(default)]
92 pub expected_bool: Option<bool>,
93 #[serde(default)]
94 pub expected_string: Option<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct NativeScenarioDescriptor {
100 pub id: String,
101 pub title: String,
102 pub category: NativeScenarioCategory,
103 pub driver: NativeDriver,
104 pub fixture: String,
105 pub spec_version: String,
106 pub assertions: Vec<NativeAssertionSpec>,
107 #[serde(default)]
108 pub notes: Option<String>,
109 #[serde(default)]
110 pub http_path: Option<String>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum NativeStatus {
116 Pass,
117 Fail,
118}
119
120impl NativeStatus {
121 pub fn label(self) -> &'static str {
122 match self {
123 Self::Pass => "pass",
124 Self::Fail => "fail",
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct NativeAssertionResult {
132 pub name: String,
133 pub status: NativeStatus,
134 #[serde(default)]
135 pub message: Option<String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct NativeScenarioResult {
141 pub scenario_id: String,
142 pub title: String,
143 pub category: NativeScenarioCategory,
144 pub driver: NativeDriver,
145 pub spec_version: String,
146 pub status: NativeStatus,
147 pub duration_ms: u64,
148 pub assertions: Vec<NativeAssertionResult>,
149 #[serde(default)]
150 pub notes: Option<String>,
151 #[serde(default)]
152 pub failure_message: Option<String>,
153}
154
155#[derive(Debug, Clone)]
156pub struct NativeConformanceRunOptions {
157 pub repo_root: PathBuf,
158 pub scenarios_dir: PathBuf,
159 pub results_output: PathBuf,
160 pub report_output: PathBuf,
161 pub peer_label: String,
162 pub stdio_command: Option<PathBuf>,
163 pub http_base_url: Option<String>,
164}
165
166#[derive(Debug, Clone)]
167pub struct NativeConformanceRunSummary {
168 pub scenario_count: usize,
169 pub results_output: PathBuf,
170 pub report_output: PathBuf,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct NativeFixtureRequest {
176 pub scenario_id: String,
177 pub request: AgentMessage,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct NativeFixtureResponse {
183 pub messages: Vec<KernelMessage>,
184}
185
186#[derive(Debug, thiserror::Error)]
187pub enum NativeSuiteError {
188 #[error("i/o error: {0}")]
189 Io(#[from] std::io::Error),
190
191 #[error("json error in {path}: {source}")]
192 Json {
193 path: String,
194 #[source]
195 source: serde_json::Error,
196 },
197
198 #[error("http driver error: {0}")]
199 Http(String),
200
201 #[error("transport error: {0}")]
202 Transport(#[from] TransportError),
203
204 #[error("fixture `{0}` is not known")]
205 UnknownFixture(String),
206
207 #[error("scenario `{scenario}` requires a stdio command")]
208 MissingStdioCommand { scenario: String },
209
210 #[error("scenario `{scenario}` requires an http base url")]
211 MissingHttpBaseUrl { scenario: String },
212
213 #[error("scenario `{scenario}` produced no terminal response")]
214 MissingTerminalResponse { scenario: String },
215}
216
217pub fn default_native_run_options() -> NativeConformanceRunOptions {
218 let repo_root = super::default_repo_root();
219 NativeConformanceRunOptions {
220 scenarios_dir: repo_root.join("tests/conformance/native/scenarios"),
221 results_output: repo_root.join("tests/conformance/native/results/generated/chio-self.json"),
222 report_output: repo_root.join("tests/conformance/native/reports/generated/chio-self.md"),
223 peer_label: "chio-self".to_string(),
224 stdio_command: None,
225 http_base_url: None,
226 repo_root,
227 }
228}
229
230pub fn run_native_conformance_suite(
231 options: &NativeConformanceRunOptions,
232) -> Result<NativeConformanceRunSummary, NativeSuiteError> {
233 let scenarios = load_native_scenarios_from_dir(&options.scenarios_dir)?;
234 if let Some(parent) = options.results_output.parent() {
235 fs::create_dir_all(parent)?;
236 }
237 if let Some(parent) = options.report_output.parent() {
238 fs::create_dir_all(parent)?;
239 }
240
241 let mut results = Vec::new();
242 for scenario in &scenarios {
243 results.push(execute_native_scenario(scenario, options)?);
244 }
245
246 fs::write(
247 &options.results_output,
248 serde_json::to_string_pretty(&results).map_err(|source| NativeSuiteError::Json {
249 path: options.results_output.display().to_string(),
250 source,
251 })?,
252 )?;
253 fs::write(
254 &options.report_output,
255 generate_native_markdown_report(&results),
256 )?;
257
258 Ok(NativeConformanceRunSummary {
259 scenario_count: results.len(),
260 results_output: options.results_output.clone(),
261 report_output: options.report_output.clone(),
262 })
263}
264
265pub fn load_native_scenarios_from_dir(
266 path: impl AsRef<Path>,
267) -> Result<Vec<NativeScenarioDescriptor>, NativeSuiteError> {
268 let mut scenarios = Vec::new();
269 let path = path.as_ref();
270 require_native_scenario_directory(path)?;
271 collect_native_scenarios(path, &mut scenarios)?;
272 if scenarios.is_empty() {
273 return Err(empty_native_scenario_directory_error(path));
274 }
275 scenarios.sort_by(|left, right| left.id.cmp(&right.id));
276 Ok(scenarios)
277}
278
279#[allow(clippy::expect_used)]
280pub fn fixture_messages_for_request(request: &AgentMessage) -> Vec<KernelMessage> {
281 match request {
282 AgentMessage::Heartbeat => vec![KernelMessage::Heartbeat],
283 AgentMessage::ListCapabilities => vec![KernelMessage::CapabilityList {
284 capabilities: vec![build_valid_capability()],
285 }],
286 AgentMessage::ToolCallRequest {
287 id,
288 capability_token,
289 tool,
290 params,
291 ..
292 } if capability_token.id == "cap-revoked-001" => {
293 vec![KernelMessage::ToolCallResponse {
294 id: id.clone(),
295 result: ToolCallResult::Err {
296 error: ToolCallError::CapabilityRevoked,
297 },
298 receipt: Box::new(build_receipt(
299 "rcpt-revoked-001",
300 &capability_token.id,
301 tool,
302 params.as_ref().clone(),
303 Decision::Deny {
304 reason: "capability revoked".to_string(),
305 guard: "revocation_store".to_string(),
306 },
307 None,
308 )),
309 execution_nonce: None,
310 }]
311 }
312 AgentMessage::ToolCallRequest {
313 id,
314 capability_token,
315 tool,
316 params,
317 ..
318 } if tool == "governed_transfer" => {
319 let metadata = serde_json::to_value(GovernedTransactionReceiptMetadata {
320 intent_id: "intent-governed-001".to_string(),
321 intent_hash: build_governed_intent()
322 .binding_hash()
323 .expect("hash deterministic governed intent"),
324 purpose: "settle supplier invoice".to_string(),
325 server_id: "conformance".to_string(),
326 tool_name: "governed_transfer".to_string(),
327 max_amount: None,
328 commerce: None,
329 metered_billing: None,
330 approval: None,
331 runtime_assurance: None,
332 call_chain: None,
333 autonomy: None,
334 economic_authorization: None,
335 })
336 .ok()
337 .map(|value| serde_json::json!({ "governed_transaction": value }));
338
339 vec![KernelMessage::ToolCallResponse {
340 id: id.clone(),
341 result: ToolCallResult::Ok {
342 value: serde_json::json!({
343 "ok": true,
344 "tool": tool,
345 "governed": true
346 }),
347 },
348 receipt: Box::new(build_receipt(
349 "rcpt-governed-001",
350 &capability_token.id,
351 tool,
352 params.as_ref().clone(),
353 Decision::Allow,
354 metadata,
355 )),
356 execution_nonce: None,
357 }]
358 }
359 AgentMessage::ToolCallRequest {
360 id,
361 capability_token,
362 tool,
363 params,
364 ..
365 } => {
366 vec![KernelMessage::ToolCallResponse {
367 id: id.clone(),
368 result: ToolCallResult::Ok {
369 value: serde_json::json!({
370 "ok": true,
371 "tool": tool,
372 "fixture": "native"
373 }),
374 },
375 receipt: Box::new(build_receipt(
376 "rcpt-ok-001",
377 &capability_token.id,
378 tool,
379 params.as_ref().clone(),
380 Decision::Allow,
381 None,
382 )),
383 execution_nonce: None,
384 }]
385 }
386 }
387}
388
389fn collect_native_scenarios(
390 path: &Path,
391 scenarios: &mut Vec<NativeScenarioDescriptor>,
392) -> Result<(), NativeSuiteError> {
393 for entry in fs::read_dir(path)? {
394 let entry = entry?;
395 let entry_path = entry.path();
396 let file_type = entry.file_type()?;
397 if file_type.is_symlink() {
398 return Err(NativeSuiteError::Io(std::io::Error::other(format!(
399 "refusing symlink in native conformance scenario tree: {}",
400 entry_path.display()
401 ))));
402 }
403 if file_type.is_dir() {
404 collect_native_scenarios(&entry_path, scenarios)?;
405 } else if file_type.is_file()
406 && entry_path.extension().and_then(|value| value.to_str()) == Some("json")
407 {
408 let content = fs::read_to_string(&entry_path)?;
409 let scenario =
410 serde_json::from_str(&content).map_err(|source| NativeSuiteError::Json {
411 path: entry_path.display().to_string(),
412 source,
413 })?;
414 scenarios.push(scenario);
415 }
416 }
417 Ok(())
418}
419
420fn require_native_scenario_directory(path: &Path) -> Result<(), NativeSuiteError> {
421 let metadata = fs::symlink_metadata(path).map_err(|source| {
422 NativeSuiteError::Io(std::io::Error::new(
423 source.kind(),
424 format!(
425 "native conformance scenario directory {} is not readable: {source}",
426 path.display()
427 ),
428 ))
429 })?;
430 let file_type = metadata.file_type();
431 if file_type.is_symlink() {
432 return Err(NativeSuiteError::Io(std::io::Error::other(format!(
433 "refusing symlinked native conformance scenario directory: {}",
434 path.display()
435 ))));
436 }
437 if !file_type.is_dir() {
438 return Err(NativeSuiteError::Io(std::io::Error::new(
439 std::io::ErrorKind::InvalidInput,
440 format!(
441 "native conformance scenario directory {} is not a directory",
442 path.display()
443 ),
444 )));
445 }
446 Ok(())
447}
448
449fn empty_native_scenario_directory_error(path: &Path) -> NativeSuiteError {
450 NativeSuiteError::Io(std::io::Error::new(
451 std::io::ErrorKind::InvalidData,
452 format!(
453 "native conformance scenario directory {} is empty: expected at least one JSON scenario",
454 path.display()
455 ),
456 ))
457}
458
459fn execute_native_scenario(
460 scenario: &NativeScenarioDescriptor,
461 options: &NativeConformanceRunOptions,
462) -> Result<NativeScenarioResult, NativeSuiteError> {
463 let start = Instant::now();
464 let outcome = match scenario.driver {
465 NativeDriver::Artifact => execute_artifact_scenario(scenario),
466 NativeDriver::Stdio => execute_stdio_scenario(scenario, options),
467 NativeDriver::Http => execute_http_scenario(scenario, options),
468 }?;
469
470 let duration_ms = start.elapsed().as_millis() as u64;
471 let status = if outcome
472 .assertions
473 .iter()
474 .all(|assertion| assertion.status == NativeStatus::Pass)
475 {
476 NativeStatus::Pass
477 } else {
478 NativeStatus::Fail
479 };
480 let failure_message = if status == NativeStatus::Fail {
481 outcome
482 .assertions
483 .iter()
484 .find(|assertion| assertion.status == NativeStatus::Fail)
485 .and_then(|assertion| assertion.message.clone())
486 } else {
487 None
488 };
489
490 Ok(NativeScenarioResult {
491 scenario_id: scenario.id.clone(),
492 title: scenario.title.clone(),
493 category: scenario.category,
494 driver: scenario.driver,
495 spec_version: scenario.spec_version.clone(),
496 status,
497 duration_ms,
498 assertions: outcome.assertions,
499 notes: scenario.notes.clone(),
500 failure_message,
501 })
502}
503
504struct ScenarioOutcome {
505 assertions: Vec<NativeAssertionResult>,
506}
507
508fn execute_artifact_scenario(
509 scenario: &NativeScenarioDescriptor,
510) -> Result<ScenarioOutcome, NativeSuiteError> {
511 let fixture = build_fixture(&scenario.fixture)?;
512 let assertions = scenario
513 .assertions
514 .iter()
515 .map(|assertion| evaluate_artifact_assertion(assertion, &fixture))
516 .collect::<Result<Vec<_>, _>>()?;
517 Ok(ScenarioOutcome { assertions })
518}
519
520fn execute_stdio_scenario(
521 scenario: &NativeScenarioDescriptor,
522 options: &NativeConformanceRunOptions,
523) -> Result<ScenarioOutcome, NativeSuiteError> {
524 let fixture = build_fixture(&scenario.fixture)?;
525 let request = fixture
526 .request()
527 .ok_or_else(|| NativeSuiteError::UnknownFixture(scenario.fixture.clone()))?;
528 let command =
529 options
530 .stdio_command
531 .as_ref()
532 .ok_or_else(|| NativeSuiteError::MissingStdioCommand {
533 scenario: scenario.id.clone(),
534 })?;
535
536 let mut child = Command::new(command)
537 .current_dir(&options.repo_root)
538 .stdin(Stdio::piped())
539 .stdout(Stdio::piped())
540 .stderr(Stdio::null())
541 .spawn()?;
542
543 let mut child_stdin = child
544 .stdin
545 .take()
546 .ok_or_else(|| NativeSuiteError::Io(std::io::Error::other("failed to open child stdin")))?;
547 let mut child_stdout = child.stdout.take().ok_or_else(|| {
548 NativeSuiteError::Io(std::io::Error::other("failed to open child stdout"))
549 })?;
550
551 let request_bytes = canonical_json_bytes(&request)
552 .map_err(|error| NativeSuiteError::Http(error.to_string()))?;
553 write_frame(&mut child_stdin, &request_bytes)?;
554 child_stdin.flush()?;
555 drop(child_stdin);
556
557 let messages = read_kernel_messages(&mut child_stdout)?;
558 let _ = child.wait();
559 let assertions = scenario
560 .assertions
561 .iter()
562 .map(|assertion| evaluate_message_assertion(assertion, &messages))
563 .collect::<Result<Vec<_>, _>>()?;
564
565 Ok(ScenarioOutcome { assertions })
566}
567
568fn execute_http_scenario(
569 scenario: &NativeScenarioDescriptor,
570 options: &NativeConformanceRunOptions,
571) -> Result<ScenarioOutcome, NativeSuiteError> {
572 let fixture = build_fixture(&scenario.fixture)?;
573 let request = fixture
574 .request()
575 .ok_or_else(|| NativeSuiteError::UnknownFixture(scenario.fixture.clone()))?;
576 let base_url =
577 options
578 .http_base_url
579 .as_ref()
580 .ok_or_else(|| NativeSuiteError::MissingHttpBaseUrl {
581 scenario: scenario.id.clone(),
582 })?;
583 let path = scenario
584 .http_path
585 .clone()
586 .unwrap_or_else(|| "/chio-conformance/v1/invoke".to_string());
587 let url = format!(
588 "{}{}",
589 base_url.trim_end_matches('/'),
590 if path.starts_with('/') {
591 path
592 } else {
593 format!("/{path}")
594 }
595 );
596 let client = Client::builder()
600 .timeout(Duration::from_secs(5))
601 .build()
602 .map_err(|error| NativeSuiteError::Http(error.to_string()))?;
603 let response = client
604 .post(&url)
605 .json(&NativeFixtureRequest {
606 scenario_id: scenario.id.clone(),
607 request,
608 })
609 .send()
612 .map_err(|error| NativeSuiteError::Http(error.to_string()))?;
613 if !response.status().is_success() {
614 return Err(NativeSuiteError::Http(format!(
615 "unexpected status {} from {url}",
616 response.status()
617 )));
618 }
619 let response: NativeFixtureResponse = response
620 .json()
621 .map_err(|error| NativeSuiteError::Http(error.to_string()))?;
622 let assertions = scenario
623 .assertions
624 .iter()
625 .map(|assertion| evaluate_message_assertion(assertion, &response.messages))
626 .collect::<Result<Vec<_>, _>>()?;
627
628 Ok(ScenarioOutcome { assertions })
629}
630
631fn read_kernel_messages(reader: &mut impl Read) -> Result<Vec<KernelMessage>, NativeSuiteError> {
632 let mut messages = Vec::new();
633 loop {
634 match read_frame(reader) {
635 Ok(frame) => {
636 let message: KernelMessage =
637 serde_json::from_slice(&frame).map_err(|source| NativeSuiteError::Json {
638 path: "<stdio>".to_string(),
639 source,
640 })?;
641 let terminal = matches!(message, KernelMessage::ToolCallResponse { .. });
642 messages.push(message);
643 if terminal {
644 break;
645 }
646 }
647 Err(TransportError::ConnectionClosed) => break,
648 Err(error) => return Err(error.into()),
649 }
650 }
651 Ok(messages)
652}
653
654fn evaluate_artifact_assertion(
655 assertion: &NativeAssertionSpec,
656 fixture: &NativeFixture,
657) -> Result<NativeAssertionResult, NativeSuiteError> {
658 match assertion.kind {
659 NativeAssertionKind::CapabilitySignatureValid => {
660 let actual = fixture
661 .valid_capability()?
662 .verify_signature()
663 .unwrap_or(false);
664 compare_bool_assertion(assertion, actual)
665 }
666 NativeAssertionKind::DelegationChainValid => {
667 let (_, child) = fixture.delegation_pair()?;
668 let actual = validate_delegation_chain(&child.delegation_chain, Some(4)).is_ok();
669 compare_bool_assertion(assertion, actual)
670 }
671 NativeAssertionKind::DelegationAttenuatesParent => {
672 let (parent, child) = fixture.delegation_pair()?;
673 let actual = validate_attenuation(&parent.scope, &child.scope).is_ok();
674 compare_bool_assertion(assertion, actual)
675 }
676 NativeAssertionKind::ReceiptSignatureValid => {
677 let actual = fixture.valid_receipt()?.verify_signature().unwrap_or(false);
678 compare_bool_assertion(assertion, actual)
679 }
680 NativeAssertionKind::ReceiptTamperRejected => {
681 let actual = !fixture
682 .tampered_receipt()?
683 .verify_signature()
684 .unwrap_or(false);
685 compare_bool_assertion(assertion, actual)
686 }
687 NativeAssertionKind::DpopProofValid => {
688 let dpop = fixture.dpop_case()?;
689 let nonce_store = DpopNonceStore::new(32, Duration::from_secs(60));
690 let actual = verify_dpop_proof(
691 dpop.proof,
692 dpop.capability,
693 dpop.expected_tool_server,
694 dpop.expected_tool_name,
695 dpop.expected_action_hash,
696 &nonce_store,
697 &DpopConfig::default(),
698 )
699 .is_ok();
700 compare_bool_assertion(assertion, actual)
701 }
702 _ => Ok(NativeAssertionResult {
703 name: assertion.name.clone(),
704 status: NativeStatus::Fail,
705 message: Some("assertion kind requires message-driven execution".to_string()),
706 }),
707 }
708}
709
710fn evaluate_message_assertion(
711 assertion: &NativeAssertionSpec,
712 messages: &[KernelMessage],
713) -> Result<NativeAssertionResult, NativeSuiteError> {
714 match assertion.kind {
715 NativeAssertionKind::TerminalStatus => {
716 let (result, _) = terminal_response(messages).ok_or_else(|| {
717 NativeSuiteError::MissingTerminalResponse {
718 scenario: assertion.name.clone(),
719 }
720 })?;
721 let actual = tool_result_status(result).to_string();
722 compare_string_assertion(assertion, actual)
723 }
724 NativeAssertionKind::ToolErrorCode => {
725 let (result, _) = terminal_response(messages).ok_or_else(|| {
726 NativeSuiteError::MissingTerminalResponse {
727 scenario: assertion.name.clone(),
728 }
729 })?;
730 let actual = match result {
731 ToolCallResult::Err { error } => tool_error_code(error).to_string(),
732 _ => "not_an_error".to_string(),
733 };
734 compare_string_assertion(assertion, actual)
735 }
736 NativeAssertionKind::ResponseReceiptSignatureValid => {
737 let (_, receipt) = terminal_response(messages).ok_or_else(|| {
738 NativeSuiteError::MissingTerminalResponse {
739 scenario: assertion.name.clone(),
740 }
741 })?;
742 let actual = receipt.verify_signature().unwrap_or(false);
743 compare_bool_assertion(assertion, actual)
744 }
745 NativeAssertionKind::GovernedReceiptPresent => {
746 let (_, receipt) = terminal_response(messages).ok_or_else(|| {
747 NativeSuiteError::MissingTerminalResponse {
748 scenario: assertion.name.clone(),
749 }
750 })?;
751 let actual = receipt
752 .metadata
753 .as_ref()
754 .and_then(|value| value.get("governed_transaction"))
755 .is_some();
756 compare_bool_assertion(assertion, actual)
757 }
758 _ => Ok(NativeAssertionResult {
759 name: assertion.name.clone(),
760 status: NativeStatus::Fail,
761 message: Some("assertion kind requires artifact execution".to_string()),
762 }),
763 }
764}
765
766fn compare_bool_assertion(
767 assertion: &NativeAssertionSpec,
768 actual: bool,
769) -> Result<NativeAssertionResult, NativeSuiteError> {
770 let expected = assertion.expected_bool.ok_or_else(|| {
771 NativeSuiteError::Http(format!(
772 "assertion {} is missing expectedBool",
773 assertion.name
774 ))
775 })?;
776 Ok(NativeAssertionResult {
777 name: assertion.name.clone(),
778 status: if actual == expected {
779 NativeStatus::Pass
780 } else {
781 NativeStatus::Fail
782 },
783 message: if actual == expected {
784 None
785 } else {
786 Some(format!("expected {expected}, got {actual}"))
787 },
788 })
789}
790
791fn compare_string_assertion(
792 assertion: &NativeAssertionSpec,
793 actual: String,
794) -> Result<NativeAssertionResult, NativeSuiteError> {
795 let expected = assertion.expected_string.clone().ok_or_else(|| {
796 NativeSuiteError::Http(format!(
797 "assertion {} is missing expectedString",
798 assertion.name
799 ))
800 })?;
801 Ok(NativeAssertionResult {
802 name: assertion.name.clone(),
803 status: if actual == expected {
804 NativeStatus::Pass
805 } else {
806 NativeStatus::Fail
807 },
808 message: if actual == expected {
809 None
810 } else {
811 Some(format!("expected `{expected}`, got `{actual}`"))
812 },
813 })
814}
815
816fn terminal_response(messages: &[KernelMessage]) -> Option<(&ToolCallResult, &ChioReceipt)> {
817 messages.iter().find_map(|message| match message {
818 KernelMessage::ToolCallResponse {
819 result, receipt, ..
820 } => Some((result, receipt.as_ref())),
821 _ => None,
822 })
823}
824
825fn tool_result_status(result: &ToolCallResult) -> &'static str {
826 match result {
827 ToolCallResult::Ok { .. } => "ok",
828 ToolCallResult::StreamComplete { .. } => "stream_complete",
829 ToolCallResult::Cancelled { .. } => "cancelled",
830 ToolCallResult::Incomplete { .. } => "incomplete",
831 ToolCallResult::Err { .. } => "err",
832 }
833}
834
835fn tool_error_code(error: &ToolCallError) -> &'static str {
836 match error {
837 ToolCallError::CapabilityDenied(_) => "capability_denied",
838 ToolCallError::CapabilityExpired => "capability_expired",
839 ToolCallError::CapabilityRevoked => "capability_revoked",
840 ToolCallError::PolicyDenied { .. } => "policy_denied",
841 ToolCallError::ToolServerError(_) => "tool_server_error",
842 ToolCallError::InternalError(_) => "internal_error",
843 }
844}
845
846fn generate_native_markdown_report(results: &[NativeScenarioResult]) -> String {
847 let mut output = String::new();
848 output.push_str("# Chio Native Conformance Report\n\n");
849 output.push_str("Generated from native conformance result artifacts.\n\n");
850
851 if results.is_empty() {
852 output.push_str("No native conformance results were generated.\n");
853 return output;
854 }
855
856 output.push_str("## Summary\n\n");
857 for category in [
858 NativeScenarioCategory::CapabilityValidation,
859 NativeScenarioCategory::DelegationAttenuation,
860 NativeScenarioCategory::ReceiptIntegrity,
861 NativeScenarioCategory::RevocationPropagation,
862 NativeScenarioCategory::DpopVerification,
863 NativeScenarioCategory::GovernedTransactionEnforcement,
864 ] {
865 let category_results = results
866 .iter()
867 .filter(|result| result.category == category)
868 .collect::<Vec<_>>();
869 if category_results.is_empty() {
870 continue;
871 }
872 let passed = category_results
873 .iter()
874 .filter(|result| result.status == NativeStatus::Pass)
875 .count();
876 output.push_str(&format!(
877 "- {}: {passed}/{} pass\n",
878 category.heading(),
879 category_results.len()
880 ));
881 }
882 output.push('\n');
883
884 for category in [
885 NativeScenarioCategory::CapabilityValidation,
886 NativeScenarioCategory::DelegationAttenuation,
887 NativeScenarioCategory::ReceiptIntegrity,
888 NativeScenarioCategory::RevocationPropagation,
889 NativeScenarioCategory::DpopVerification,
890 NativeScenarioCategory::GovernedTransactionEnforcement,
891 ] {
892 let category_results = results
893 .iter()
894 .filter(|result| result.category == category)
895 .collect::<Vec<_>>();
896 if category_results.is_empty() {
897 continue;
898 }
899 output.push_str(&format!("## {}\n\n", category.heading()));
900 output.push_str("| Scenario | Driver | Status | Duration |\n");
901 output.push_str("| --- | --- | --- | --- |\n");
902 for result in category_results {
903 output.push_str(&format!(
904 "| `{}` | `{}` | `{}` | {} ms |\n",
905 result.scenario_id,
906 result.driver.label(),
907 result.status.label(),
908 result.duration_ms
909 ));
910 }
911 output.push('\n');
912 }
913
914 let failures = results
915 .iter()
916 .filter(|result| result.status == NativeStatus::Fail)
917 .collect::<Vec<_>>();
918 if !failures.is_empty() {
919 output.push_str("## Failures\n\n");
920 for failure in failures {
921 output.push_str(&format!(
922 "- `{}`: {}\n",
923 failure.scenario_id,
924 failure
925 .failure_message
926 .as_deref()
927 .unwrap_or("scenario failed without a recorded failure message")
928 ));
929 }
930 }
931
932 output
933}
934
935enum NativeFixture {
936 Capability(Box<CapabilityToken>),
937 Delegation {
938 parent: Box<CapabilityToken>,
939 child: Box<CapabilityToken>,
940 },
941 Receipt {
942 valid: Box<ChioReceipt>,
943 tampered: Box<ChioReceipt>,
944 },
945 Dpop {
946 proof: Box<DpopProof>,
947 capability: Box<CapabilityToken>,
948 expected_tool_server: String,
949 expected_tool_name: String,
950 expected_action_hash: String,
951 },
952 Request(AgentMessage),
953}
954
955impl NativeFixture {
956 fn valid_capability(&self) -> Result<&CapabilityToken, NativeSuiteError> {
957 match self {
958 Self::Capability(token) => Ok(token),
959 _ => Err(NativeSuiteError::Http(
960 "fixture is not a capability".to_string(),
961 )),
962 }
963 }
964
965 fn delegation_pair(&self) -> Result<(&CapabilityToken, &CapabilityToken), NativeSuiteError> {
966 match self {
967 Self::Delegation { parent, child } => Ok((parent.as_ref(), child.as_ref())),
968 _ => Err(NativeSuiteError::Http(
969 "fixture is not a delegation pair".to_string(),
970 )),
971 }
972 }
973
974 fn valid_receipt(&self) -> Result<&ChioReceipt, NativeSuiteError> {
975 match self {
976 Self::Receipt { valid, .. } => Ok(valid.as_ref()),
977 _ => Err(NativeSuiteError::Http(
978 "fixture is not a receipt".to_string(),
979 )),
980 }
981 }
982
983 fn tampered_receipt(&self) -> Result<&ChioReceipt, NativeSuiteError> {
984 match self {
985 Self::Receipt { tampered, .. } => Ok(tampered.as_ref()),
986 _ => Err(NativeSuiteError::Http(
987 "fixture is not a receipt".to_string(),
988 )),
989 }
990 }
991
992 fn dpop_case(&self) -> Result<DpopCase<'_>, NativeSuiteError> {
993 match self {
994 Self::Dpop {
995 proof,
996 capability,
997 expected_tool_server,
998 expected_tool_name,
999 expected_action_hash,
1000 } => Ok(DpopCase {
1001 proof: proof.as_ref(),
1002 capability,
1003 expected_tool_server,
1004 expected_tool_name,
1005 expected_action_hash,
1006 }),
1007 _ => Err(NativeSuiteError::Http(
1008 "fixture is not a dpop case".to_string(),
1009 )),
1010 }
1011 }
1012
1013 fn request(&self) -> Option<AgentMessage> {
1014 match self {
1015 Self::Request(request) => Some(request.clone()),
1016 _ => None,
1017 }
1018 }
1019}
1020
1021struct DpopCase<'a> {
1022 proof: &'a DpopProof,
1023 capability: &'a CapabilityToken,
1024 expected_tool_server: &'a str,
1025 expected_tool_name: &'a str,
1026 expected_action_hash: &'a str,
1027}
1028
1029fn build_fixture(id: &str) -> Result<NativeFixture, NativeSuiteError> {
1030 match id {
1031 "valid_capability" => Ok(NativeFixture::Capability(
1032 Box::new(build_valid_capability()),
1033 )),
1034 "delegation_pair" => {
1035 let (parent, child) = build_delegation_pair();
1036 Ok(NativeFixture::Delegation {
1037 parent: Box::new(parent),
1038 child: Box::new(child),
1039 })
1040 }
1041 "signed_receipt" => {
1042 let valid = build_receipt(
1043 "rcpt-integrity-001",
1044 "cap-valid-001",
1045 "echo",
1046 serde_json::json!({"text": "hello"}),
1047 Decision::Allow,
1048 None,
1049 );
1050 let mut tampered = valid.clone();
1051 tampered.tool_name = "tampered".to_string();
1052 Ok(NativeFixture::Receipt {
1053 valid: Box::new(valid),
1054 tampered: Box::new(tampered),
1055 })
1056 }
1057 "valid_dpop" => {
1058 let capability = build_dpop_capability();
1059 let params = serde_json::json!({"amount": 25, "currency": "USD"});
1060 let action_hash = chio_core::sha256_hex(
1061 &canonical_json_bytes(¶ms)
1062 .map_err(|error| NativeSuiteError::Http(error.to_string()))?,
1063 );
1064 let proof = DpopProof::sign(
1065 DpopProofBody {
1066 schema: chio_kernel::dpop::DPOP_SCHEMA.to_string(),
1067 capability_id: capability.id.clone(),
1068 tool_server: "conformance".to_string(),
1069 tool_name: "transfer".to_string(),
1070 action_hash: action_hash.clone(),
1071 nonce: "nonce-001".to_string(),
1072 issued_at: current_unix_timestamp(),
1073 agent_key: dpop_subject_keypair().public_key(),
1074 },
1075 &dpop_subject_keypair(),
1076 )
1077 .map_err(|error| NativeSuiteError::Http(error.to_string()))?;
1078 Ok(NativeFixture::Dpop {
1079 proof: Box::new(proof),
1080 capability: Box::new(capability),
1081 expected_tool_server: "conformance".to_string(),
1082 expected_tool_name: "transfer".to_string(),
1083 expected_action_hash: action_hash,
1084 })
1085 }
1086 "revoked_capability_request" => Ok(NativeFixture::Request(build_revoked_request())),
1087 "governed_request" => Ok(NativeFixture::Request(build_governed_request())),
1088 other => Err(NativeSuiteError::UnknownFixture(other.to_string())),
1089 }
1090}
1091
1092fn authority_keypair() -> Keypair {
1093 Keypair::from_seed(&[7u8; 32])
1094}
1095
1096fn capability_subject_keypair() -> Keypair {
1097 Keypair::from_seed(&[11u8; 32])
1098}
1099
1100fn delegated_subject_keypair() -> Keypair {
1101 Keypair::from_seed(&[13u8; 32])
1102}
1103
1104fn dpop_subject_keypair() -> Keypair {
1105 Keypair::from_seed(&[17u8; 32])
1106}
1107
1108fn kernel_keypair() -> Keypair {
1109 Keypair::from_seed(&[23u8; 32])
1110}
1111
1112fn build_scope(
1113 tool_name: &str,
1114 dpop_required: Option<bool>,
1115 constraints: Vec<Constraint>,
1116) -> ChioScope {
1117 ChioScope {
1118 grants: vec![ToolGrant {
1119 server_id: "conformance".to_string(),
1120 tool_name: tool_name.to_string(),
1121 operations: vec![Operation::Invoke],
1122 constraints,
1123 max_invocations: Some(5),
1124 max_cost_per_invocation: None,
1125 max_total_cost: None,
1126 dpop_required,
1127 }],
1128 ..ChioScope::default()
1129 }
1130}
1131
1132#[allow(clippy::expect_used)]
1133fn build_capability(
1134 id: &str,
1135 subject: &Keypair,
1136 scope: ChioScope,
1137 delegation_chain: Vec<DelegationLink>,
1138) -> CapabilityToken {
1139 CapabilityToken::sign(
1140 CapabilityTokenBody {
1141 id: id.to_string(),
1142 issuer: authority_keypair().public_key(),
1143 subject: subject.public_key(),
1144 scope,
1145 issued_at: 1_700_000_000,
1146 expires_at: 1_800_000_000,
1147 delegation_chain,
1148 aggregate_invocation_budget: None,
1149 },
1150 &authority_keypair(),
1151 )
1152 .expect("sign deterministic capability")
1153}
1154
1155fn build_valid_capability() -> CapabilityToken {
1156 build_capability(
1157 "cap-valid-001",
1158 &capability_subject_keypair(),
1159 build_scope("echo", None, vec![]),
1160 vec![],
1161 )
1162}
1163
1164fn build_dpop_capability() -> CapabilityToken {
1165 build_capability(
1166 "cap-dpop-001",
1167 &dpop_subject_keypair(),
1168 build_scope("transfer", Some(true), vec![]),
1169 vec![],
1170 )
1171}
1172
1173#[allow(clippy::expect_used)]
1174fn build_delegation_pair() -> (CapabilityToken, CapabilityToken) {
1175 let parent_subject = capability_subject_keypair();
1176 let child_subject = delegated_subject_keypair();
1177 let parent = build_capability(
1178 "cap-parent-001",
1179 &parent_subject,
1180 build_scope("echo", None, vec![]),
1181 vec![],
1182 );
1183 let child_scope = ChioScope {
1184 grants: vec![ToolGrant {
1185 server_id: "conformance".to_string(),
1186 tool_name: "echo".to_string(),
1187 operations: vec![Operation::Invoke],
1188 constraints: vec![Constraint::MaxLength(32)],
1189 max_invocations: Some(1),
1190 max_cost_per_invocation: None,
1191 max_total_cost: None,
1192 dpop_required: None,
1193 }],
1194 ..ChioScope::default()
1195 };
1196 let child_scope_hash = scope_hash(&child_scope).expect("hash child delegation scope");
1197 let delegation = DelegationLink::sign(
1198 DelegationLinkBody {
1199 capability_id: parent.id.clone(),
1200 delegator: parent_subject.public_key(),
1201 delegatee: child_subject.public_key(),
1202 attenuations: vec![
1203 Attenuation::ReduceBudget {
1204 server_id: "conformance".to_string(),
1205 tool_name: "echo".to_string(),
1206 max_invocations: 1,
1207 },
1208 Attenuation::AddConstraint {
1209 server_id: "conformance".to_string(),
1210 tool_name: "echo".to_string(),
1211 constraint: Constraint::MaxLength(32),
1212 },
1213 ],
1214 timestamp: 1_700_000_100,
1215 scope_hash: Some(child_scope_hash),
1216 aggregate_budget: None,
1217 cumulative_approval: None,
1218 },
1219 &parent_subject,
1220 )
1221 .expect("sign deterministic delegation");
1222
1223 let child = build_capability(
1224 "cap-child-001",
1225 &child_subject,
1226 child_scope,
1227 vec![delegation],
1228 );
1229 (parent, child)
1230}
1231
1232fn build_governed_intent() -> GovernedTransactionIntent {
1233 GovernedTransactionIntent {
1234 id: "intent-governed-001".to_string(),
1235 server_id: "conformance".to_string(),
1236 tool_name: "governed_transfer".to_string(),
1237 purpose: "settle supplier invoice".to_string(),
1238 max_amount: None,
1239 commerce: None,
1240 metered_billing: None,
1241 runtime_attestation: None,
1242 call_chain: None,
1243 autonomy: None,
1244 context: Some(serde_json::json!({
1245 "amount": 1250,
1246 "currency": "USD",
1247 "seller": "supplier-001"
1248 })),
1249 body: Default::default(),
1250 }
1251}
1252
1253fn build_governed_request() -> AgentMessage {
1254 AgentMessage::ToolCallRequest {
1255 id: "req-governed-001".to_string(),
1256 capability_token: Box::new(build_capability(
1257 "cap-governed-001",
1258 &capability_subject_keypair(),
1259 build_scope(
1260 "governed_transfer",
1261 None,
1262 vec![Constraint::GovernedIntentRequired],
1263 ),
1264 vec![],
1265 )),
1266 server_id: "conformance".to_string(),
1267 tool: "governed_transfer".to_string(),
1268 params: Box::new(serde_json::json!({
1269 "amount": 1250,
1270 "currency": "USD",
1271 "seller": "supplier-001"
1272 })),
1273 governed_intent: None,
1274 approval_token: None,
1275 approval_tokens: Vec::new(),
1276 threshold_approval_proposal: None,
1277 supplemental_authorization: None,
1278 execution_nonce: None,
1279 }
1280}
1281
1282fn build_revoked_request() -> AgentMessage {
1283 AgentMessage::ToolCallRequest {
1284 id: "req-revoked-001".to_string(),
1285 capability_token: Box::new(build_capability(
1286 "cap-revoked-001",
1287 &capability_subject_keypair(),
1288 build_scope("echo", None, vec![]),
1289 vec![],
1290 )),
1291 server_id: "conformance".to_string(),
1292 tool: "echo".to_string(),
1293 params: Box::new(serde_json::json!({"text": "hello"})),
1294 governed_intent: None,
1295 approval_token: None,
1296 approval_tokens: Vec::new(),
1297 threshold_approval_proposal: None,
1298 supplemental_authorization: None,
1299 execution_nonce: None,
1300 }
1301}
1302
1303#[allow(clippy::expect_used)]
1304fn build_receipt(
1305 receipt_id: &str,
1306 capability_id: &str,
1307 tool_name: &str,
1308 params: serde_json::Value,
1309 decision: Decision,
1310 metadata: Option<serde_json::Value>,
1311) -> ChioReceipt {
1312 ChioReceipt::sign(
1313 ChioReceiptBody {
1314 id: receipt_id.to_string(),
1315 timestamp: 1_700_000_200,
1316 capability_id: capability_id.to_string(),
1317 tool_server: "conformance".to_string(),
1318 tool_name: tool_name.to_string(),
1319 action: ToolCallAction::from_parameters(params).expect("build action"),
1320 decision: Some(decision),
1321 receipt_kind: ReceiptKind::MediatedDecision,
1322 boundary_class: BoundaryClass::Prevent,
1323 observation_outcome: None,
1324 tool_origin: ToolOrigin::CallerExecuted,
1325 redaction_mode: RedactionMode::None,
1326 actor_chain: Vec::new(),
1327 content_hash: chio_core::sha256_hex(b"{\"ok\":true}"),
1328 policy_hash: "policy-hash-001".to_string(),
1329 evidence: vec![GuardEvidence {
1330 guard_name: "ConformanceGuard".to_string(),
1331 verdict: true,
1332 details: None,
1333 }],
1334 metadata,
1335 trust_level: chio_core::receipt::kinds::TrustLevel::default(),
1336 tenant_id: None,
1337 kernel_key: kernel_keypair().public_key(),
1338 bbs_projection_version: None,
1339 },
1340 &kernel_keypair(),
1341 )
1342 .expect("sign deterministic receipt")
1343}
1344
1345fn current_unix_timestamp() -> u64 {
1346 SystemTime::now()
1347 .duration_since(UNIX_EPOCH)
1348 .map(|duration| duration.as_secs())
1349 .unwrap_or(0)
1350}
1351
1352#[cfg(test)]
1353#[allow(clippy::expect_used, clippy::unwrap_used)]
1354mod tests {
1355 use super::*;
1356
1357 #[test]
1358 fn load_native_scenarios_reads_checked_in_suite() {
1359 let repo_root = crate::default_repo_root();
1360 let scenarios =
1361 load_native_scenarios_from_dir(repo_root.join("tests/conformance/native/scenarios"))
1362 .expect("load native scenarios");
1363 assert_eq!(scenarios.len(), 6);
1364 assert!(scenarios
1365 .iter()
1366 .any(|scenario| { scenario.category == NativeScenarioCategory::CapabilityValidation }));
1367 assert!(scenarios.iter().any(|scenario| {
1368 scenario.category == NativeScenarioCategory::GovernedTransactionEnforcement
1369 }));
1370 }
1371
1372 #[test]
1373 fn load_native_scenarios_rejects_missing_directory() {
1374 let missing = std::env::temp_dir().join(format!(
1375 "chio-conformance-native-missing-{}",
1376 std::process::id()
1377 ));
1378 let _ = fs::remove_dir_all(&missing);
1379
1380 match load_native_scenarios_from_dir(&missing) {
1381 Ok(scenarios) => panic!("missing native scenario directory should fail: {scenarios:?}"),
1382 Err(error) => assert!(error.to_string().contains("directory")),
1383 }
1384 }
1385
1386 #[test]
1387 fn load_native_scenarios_rejects_empty_directory() {
1388 let dir = std::env::temp_dir().join(format!(
1389 "chio-conformance-native-empty-{}",
1390 std::process::id()
1391 ));
1392 let _ = fs::remove_dir_all(&dir);
1393 fs::create_dir_all(&dir).expect("create empty native scenario dir");
1394
1395 match load_native_scenarios_from_dir(&dir) {
1396 Ok(scenarios) => panic!("empty native scenario directory should fail: {scenarios:?}"),
1397 Err(error) => assert!(error.to_string().contains("empty")),
1398 }
1399
1400 let _ = fs::remove_dir_all(dir);
1401 }
1402
1403 #[cfg(unix)]
1404 #[test]
1405 fn load_native_scenarios_rejects_symlinked_json_file() {
1406 let dir = std::env::temp_dir().join(format!(
1407 "chio-conformance-native-symlink-{}",
1408 std::process::id()
1409 ));
1410 let outside = std::env::temp_dir().join(format!(
1411 "chio-conformance-native-symlink-outside-{}",
1412 std::process::id()
1413 ));
1414 let _ = fs::remove_dir_all(&dir);
1415 let _ = fs::remove_dir_all(&outside);
1416 fs::create_dir_all(&dir).expect("create native scenario dir");
1417 fs::create_dir_all(&outside).expect("create outside dir");
1418 fs::write(
1419 outside.join("escape.json"),
1420 r#"{
1421 "id": "escape",
1422 "title": "Escape",
1423 "category": "capability_validation",
1424 "driver": "artifact",
1425 "fixture": "valid-capability",
1426 "specVersion": "1.0",
1427 "assertions": []
1428 }"#,
1429 )
1430 .expect("write outside scenario");
1431 std::os::unix::fs::symlink(outside.join("escape.json"), dir.join("escape.json"))
1432 .expect("create scenario symlink");
1433
1434 match load_native_scenarios_from_dir(&dir) {
1435 Ok(scenarios) => panic!("symlinked native scenario should fail: {scenarios:?}"),
1436 Err(error) => assert!(error.to_string().contains("symlink")),
1437 }
1438
1439 let _ = fs::remove_dir_all(dir);
1440 let _ = fs::remove_dir_all(outside);
1441 }
1442
1443 #[test]
1444 fn native_fixture_responses_include_governed_receipt_metadata() {
1445 let request = build_governed_request();
1446 let messages = fixture_messages_for_request(&request);
1447 let (_, receipt) = terminal_response(&messages).expect("terminal response");
1448 assert!(receipt.verify_signature().expect("verify signature"));
1449 assert!(receipt
1450 .metadata
1451 .as_ref()
1452 .and_then(|value| value.get("governed_transaction"))
1453 .is_some());
1454 }
1455}