diff --git a/Cargo.lock b/Cargo.lock index ba8cd4b..7fabe73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-pack" +version = "0.1.0" +dependencies = [ + "clap", + "hex", + "risc0-zkvm", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "ahash" version = "0.8.12" @@ -715,6 +729,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -1438,6 +1502,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "cobs" version = "0.3.0" @@ -1447,6 +1551,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const-hex" version = "1.17.0" @@ -2895,6 +3005,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -3531,6 +3647,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.75" @@ -5773,6 +5895,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index e399d0b..3806f11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ members = [ # Testing crates "crates/testing/kernel-host-tests", "crates/testing/e2e-tests", + + # Tools + "crates/agent-pack", ] [workspace.dependencies] diff --git a/README.md b/README.md index c4255de..ae16fa3 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,31 @@ let journal = KernelJournalV1::decode(&journal_bytes)?; 7. Construct canonical journal (Success or Failure) 8. Commit journal or abort on hard error +## Agent Pack + +Agent Pack is a portable bundle format for distributing verifiable agents. It allows third-party developers to package their agents with all cryptographic commitments needed for offline verification. + +### Installation + +```bash +cargo install --path crates/agent-pack --features risc0 +``` + +### Quick Start + +```bash +# Initialize a new manifest +agent-pack init --name my-agent --version 1.0.0 --agent-id 0x0000...0042 + +# Compute hashes from ELF binary +agent-pack compute --elf target/riscv-guest/.../zkvm-guest --out dist/agent-pack.json + +# Verify the manifest +agent-pack verify --manifest dist/agent-pack.json +``` + +See [docs/agent-pack.md](docs/agent-pack.md) for full documentation. + ## Consensus-Critical Properties This implementation prioritizes **determinism and correctness over convenience**: diff --git a/crates/agent-pack/Cargo.toml b/crates/agent-pack/Cargo.toml new file mode 100644 index 0000000..0d660d9 --- /dev/null +++ b/crates/agent-pack/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "agent-pack" +version = "0.1.0" +edition = "2021" +description = "CLI tool for creating and verifying Agent Pack bundles" +license = "MIT OR Apache-2.0" + +[[bin]] +name = "agent-pack" +path = "src/bin/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { workspace = true } +hex = "0.4" +thiserror = "2" + +# Optional: for IMAGE_ID computation +risc0-zkvm = { version = "3.0", optional = true, default-features = false } + +[dev-dependencies] +tempfile = "3" + +[features] +default = [] +risc0 = ["dep:risc0-zkvm"] diff --git a/crates/agent-pack/src/bin/main.rs b/crates/agent-pack/src/bin/main.rs new file mode 100644 index 0000000..1b7e2ec --- /dev/null +++ b/crates/agent-pack/src/bin/main.rs @@ -0,0 +1,302 @@ +//! Agent Pack CLI - Create and verify agent bundles. + +use agent_pack::{ + format_hex, sha256_file, validate_hex_32, verify_manifest_structure, + verify_manifest_with_files, AgentPackManifest, +}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "agent-pack")] +#[command(about = "Create and verify Agent Pack bundles for verifiable agent distribution")] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new Agent Pack manifest with placeholder values + Init { + /// Agent name (e.g., "yield-agent") + #[arg(short, long)] + name: String, + + /// Agent version in semver format (e.g., "1.0.0") + #[arg(short, long)] + version: String, + + /// 32-byte agent ID as hex with 0x prefix + #[arg(short, long)] + agent_id: String, + + /// Output file path [default: ./dist/agent-pack.json] + #[arg(short, long)] + out: Option, + }, + + /// Compute hashes from ELF binary and update manifest + Compute { + /// Path to the ELF binary + #[arg(short, long)] + elf: PathBuf, + + /// Path to manifest file to update [default: ./dist/agent-pack.json] + #[arg(short, long)] + out: Option, + + /// Path to Cargo.lock for hash computation + #[arg(long)] + cargo_lock: Option, + }, + + /// Verify an Agent Pack manifest + Verify { + /// Path to manifest file [default: ./dist/agent-pack.json] + #[arg(short, long)] + manifest: Option, + + /// Base directory for resolving relative paths + #[arg(short, long)] + base_dir: Option, + + /// Only verify manifest structure, skip file verification + #[arg(long)] + structure_only: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + match cli.command { + Commands::Init { + name, + version, + agent_id, + out, + } => cmd_init(name, version, agent_id, out), + Commands::Compute { + elf, + out, + cargo_lock, + } => cmd_compute(elf, out, cargo_lock), + Commands::Verify { + manifest, + base_dir, + structure_only, + } => cmd_verify(manifest, base_dir, structure_only), + } +} + +fn cmd_init(name: String, version: String, agent_id: String, out: Option) -> ExitCode { + // Validate agent_id format + if let Err(e) = validate_hex_32(&agent_id) { + eprintln!("Error: invalid agent_id: {}", e); + return ExitCode::FAILURE; + } + + // Validate version is semver-like + if !is_valid_semver(&version) { + eprintln!("Error: invalid version '{}' - must be semver format (e.g., 1.0.0)", version); + return ExitCode::FAILURE; + } + + // Create manifest + let manifest = AgentPackManifest::new_template(name, version, agent_id); + + // Determine output path + let out_path = out.unwrap_or_else(|| PathBuf::from("./dist/agent-pack.json")); + + // Create parent directory if needed + if let Some(parent) = out_path.parent() { + if !parent.exists() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!("Error: could not create directory {}: {}", parent.display(), e); + return ExitCode::FAILURE; + } + } + } + + // Write manifest + if let Err(e) = manifest.to_file(&out_path) { + eprintln!("Error: could not write manifest: {}", e); + return ExitCode::FAILURE; + } + + println!("Created Agent Pack manifest: {}", out_path.display()); + println!(); + println!("Next steps:"); + println!(" 1. Fill in the 'inputs' and 'actions_profile' fields"); + println!(" 2. Run 'agent-pack compute --elf ' to compute hashes"); + println!(" 3. Run 'agent-pack verify' to validate the manifest"); + + ExitCode::SUCCESS +} + +fn cmd_compute(elf: PathBuf, out: Option, cargo_lock: Option) -> ExitCode { + // Check ELF exists + if !elf.exists() { + eprintln!("Error: ELF file not found: {}", elf.display()); + return ExitCode::FAILURE; + } + + // Compute ELF hash + let elf_hash = match sha256_file(&elf) { + Ok(h) => h, + Err(e) => { + eprintln!("Error: could not read ELF file: {}", e); + return ExitCode::FAILURE; + } + }; + let elf_sha256 = format_hex(&elf_hash); + + // Compute IMAGE_ID if risc0 feature is enabled + #[cfg(feature = "risc0")] + let image_id = { + use agent_pack::compute_image_id_from_file; + match compute_image_id_from_file(&elf) { + Ok(id) => Some(format_hex(&id)), + Err(e) => { + eprintln!("Warning: could not compute IMAGE_ID: {}", e); + None + } + } + }; + + #[cfg(not(feature = "risc0"))] + let image_id: Option = { + eprintln!("Note: IMAGE_ID computation requires --features risc0"); + None + }; + + // Compute Cargo.lock hash if provided + let cargo_lock_sha256 = if let Some(lock_path) = cargo_lock { + match sha256_file(&lock_path) { + Ok(h) => Some(format_hex(&h)), + Err(e) => { + eprintln!("Warning: could not read Cargo.lock: {}", e); + None + } + } + } else { + None + }; + + // Determine manifest path + let manifest_path = out.unwrap_or_else(|| PathBuf::from("./dist/agent-pack.json")); + + // Load or create manifest + let mut manifest = if manifest_path.exists() { + match AgentPackManifest::from_file(&manifest_path) { + Ok(m) => m, + Err(e) => { + eprintln!("Error: could not read manifest: {}", e); + return ExitCode::FAILURE; + } + } + } else { + eprintln!("Error: manifest not found: {}", manifest_path.display()); + eprintln!("Run 'agent-pack init' first to create a manifest"); + return ExitCode::FAILURE; + }; + + // Update manifest + manifest.artifacts.elf_sha256 = elf_sha256.clone(); + manifest.artifacts.elf_path = elf.to_string_lossy().to_string(); + + if let Some(id) = image_id.clone() { + manifest.image_id = id; + } + + if let Some(lock_hash) = cargo_lock_sha256.clone() { + manifest.build.cargo_lock_sha256 = lock_hash; + } + + // Write updated manifest + if let Err(e) = manifest.to_file(&manifest_path) { + eprintln!("Error: could not write manifest: {}", e); + return ExitCode::FAILURE; + } + + println!("Updated manifest: {}", manifest_path.display()); + println!(); + println!("Computed values:"); + println!(" elf_sha256: {}", elf_sha256); + if let Some(id) = image_id { + println!(" image_id: {}", id); + } + if let Some(lock_hash) = cargo_lock_sha256 { + println!(" cargo_lock_sha256: {}", lock_hash); + } + + ExitCode::SUCCESS +} + +fn cmd_verify( + manifest: Option, + base_dir: Option, + structure_only: bool, +) -> ExitCode { + let manifest_path = manifest.unwrap_or_else(|| PathBuf::from("./dist/agent-pack.json")); + + // Load manifest + let manifest = match AgentPackManifest::from_file(&manifest_path) { + Ok(m) => m, + Err(e) => { + eprintln!("Error: could not read manifest: {}", e); + return ExitCode::FAILURE; + } + }; + + println!("Verifying: {}", manifest_path.display()); + println!(" Agent: {} v{}", manifest.agent_name, manifest.agent_version); + println!(" Agent ID: {}", manifest.agent_id); + println!(); + + // Run verification + let report = if structure_only { + verify_manifest_structure(&manifest) + } else { + let base = base_dir.unwrap_or_else(|| { + manifest_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + }); + verify_manifest_with_files(&manifest, &base) + }; + + // Print report + println!("{}", report); + + if report.passed { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +/// Simple semver validation (same as in verify.rs) +fn is_valid_semver(version: &str) -> bool { + let parts: Vec<&str> = version.split(|c| c == '-' || c == '+').collect(); + if parts.is_empty() { + return false; + } + + let version_core: Vec<&str> = parts[0].split('.').collect(); + if version_core.len() != 3 { + return false; + } + + for part in version_core { + if part.is_empty() || part.parse::().is_err() { + return false; + } + } + + true +} diff --git a/crates/agent-pack/src/hash.rs b/crates/agent-pack/src/hash.rs new file mode 100644 index 0000000..ee2dd09 --- /dev/null +++ b/crates/agent-pack/src/hash.rs @@ -0,0 +1,147 @@ +//! SHA-256 hashing utilities for Agent Pack. +//! +//! Provides functions for computing and formatting SHA-256 hashes +//! in the format expected by Agent Pack manifests. + +use sha2::{Digest, Sha256}; +use std::path::Path; + +/// Computes SHA-256 hash of the given bytes. +/// +/// Returns a 32-byte array containing the hash. +pub fn sha256(data: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +/// Computes SHA-256 hash of a file. +/// +/// # Errors +/// +/// Returns an error if the file cannot be read. +pub fn sha256_file(path: &Path) -> Result<[u8; 32], std::io::Error> { + let data = std::fs::read(path)?; + Ok(sha256(&data)) +} + +/// Formats a 32-byte hash as a hex string with 0x prefix. +/// +/// This is the canonical format for hashes in Agent Pack manifests. +pub fn format_hex(hash: &[u8; 32]) -> String { + format!("0x{}", hex::encode(hash)) +} + +/// Parses a hex string (with or without 0x prefix) into a 32-byte array. +/// +/// # Errors +/// +/// Returns an error if the string is not valid hex or not exactly 32 bytes. +pub fn parse_hex_32(s: &str) -> Result<[u8; 32], HexError> { + let s = s.strip_prefix("0x").unwrap_or(s); + + if s.len() != 64 { + return Err(HexError::InvalidLength { + expected: 64, + actual: s.len(), + }); + } + + let bytes = hex::decode(s).map_err(|e| HexError::InvalidHex(e.to_string()))?; + + bytes + .try_into() + .map_err(|_| HexError::InvalidLength { expected: 64, actual: s.len() }) +} + +/// Validates that a string is a valid 32-byte hex value with 0x prefix. +/// +/// Returns `Ok(())` if valid, or an error describing the problem. +pub fn validate_hex_32(s: &str) -> Result<(), HexError> { + if !s.starts_with("0x") { + return Err(HexError::MissingPrefix); + } + + let _ = parse_hex_32(s)?; + Ok(()) +} + +/// Errors that can occur when parsing hex strings. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum HexError { + #[error("hex string must start with '0x' prefix")] + MissingPrefix, + + #[error("invalid hex string length: expected {expected} chars, got {actual}")] + InvalidLength { expected: usize, actual: usize }, + + #[error("invalid hex characters: {0}")] + InvalidHex(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sha256_known_value() { + // SHA-256 of empty string + let hash = sha256(b""); + let hex = format_hex(&hash); + assert_eq!( + hex, + "0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn test_sha256_hello_world() { + let hash = sha256(b"hello world"); + let hex = format_hex(&hash); + assert_eq!( + hex, + "0xb94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn test_format_and_parse_roundtrip() { + let original = [0x42u8; 32]; + let hex = format_hex(&original); + let parsed = parse_hex_32(&hex).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_parse_hex_without_prefix() { + let hex = "4242424242424242424242424242424242424242424242424242424242424242"; + let result = parse_hex_32(hex).unwrap(); + assert_eq!(result, [0x42u8; 32]); + } + + #[test] + fn test_validate_hex_valid() { + let valid = "0x0000000000000000000000000000000000000000000000000000000000000001"; + assert!(validate_hex_32(valid).is_ok()); + } + + #[test] + fn test_validate_hex_missing_prefix() { + let no_prefix = "0000000000000000000000000000000000000000000000000000000000000001"; + assert_eq!(validate_hex_32(no_prefix), Err(HexError::MissingPrefix)); + } + + #[test] + fn test_validate_hex_wrong_length() { + let too_short = "0x0001"; + let result = validate_hex_32(too_short); + assert!(matches!(result, Err(HexError::InvalidLength { .. }))); + } + + #[test] + fn test_validate_hex_bad_chars() { + let bad_chars = "0xGGGG000000000000000000000000000000000000000000000000000000000001"; + let result = validate_hex_32(bad_chars); + assert!(matches!(result, Err(HexError::InvalidHex(_)))); + } +} diff --git a/crates/agent-pack/src/image_id.rs b/crates/agent-pack/src/image_id.rs new file mode 100644 index 0000000..8754233 --- /dev/null +++ b/crates/agent-pack/src/image_id.rs @@ -0,0 +1,111 @@ +//! IMAGE_ID computation for RISC Zero ELF binaries. +//! +//! This module provides functionality to compute the IMAGE_ID from an ELF binary, +//! which is the cryptographic commitment used for on-chain verification. +//! +//! The IMAGE_ID computation requires the `risc0` feature to be enabled. + +use std::path::Path; + +/// Error type for IMAGE_ID computation. +#[derive(Debug, thiserror::Error)] +pub enum ImageIdError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("IMAGE_ID computation failed: {0}")] + Computation(String), + + #[error("risc0 feature not enabled - cannot compute IMAGE_ID")] + FeatureNotEnabled, +} + +/// Computes the IMAGE_ID from an ELF binary file. +/// +/// The IMAGE_ID is a 32-byte cryptographic commitment to the ELF binary that +/// uniquely identifies the program. This is the value registered on-chain +/// for proof verification. +/// +/// # Arguments +/// +/// * `elf_path` - Path to the ELF binary file +/// +/// # Returns +/// +/// A 32-byte array containing the IMAGE_ID in little-endian format, +/// matching the on-chain representation. +/// +/// # Errors +/// +/// Returns an error if: +/// - The `risc0` feature is not enabled +/// - The file cannot be read +/// - The ELF binary is invalid +#[cfg(feature = "risc0")] +pub fn compute_image_id_from_file(elf_path: &Path) -> Result<[u8; 32], ImageIdError> { + let elf_bytes = std::fs::read(elf_path)?; + compute_image_id_from_bytes(&elf_bytes) +} + +/// Computes the IMAGE_ID from ELF binary bytes. +/// +/// See [`compute_image_id_from_file`] for details. +#[cfg(feature = "risc0")] +pub fn compute_image_id_from_bytes(elf_bytes: &[u8]) -> Result<[u8; 32], ImageIdError> { + use risc0_zkvm::compute_image_id; + + let digest = compute_image_id(elf_bytes) + .map_err(|e| ImageIdError::Computation(e.to_string()))?; + + // Convert Digest to [u32; 8], then to [u8; 32] little-endian. + // This matches the on-chain format used in print_registration_info.rs: + // ZKVM_GUEST_ID.iter().flat_map(|x| x.to_le_bytes()).collect() + let words: [u32; 8] = digest.into(); + let mut bytes = [0u8; 32]; + for (i, word) in words.iter().enumerate() { + bytes[i * 4..(i + 1) * 4].copy_from_slice(&word.to_le_bytes()); + } + + Ok(bytes) +} + +/// Stub implementation when risc0 feature is not enabled. +#[cfg(not(feature = "risc0"))] +pub fn compute_image_id_from_file(_elf_path: &Path) -> Result<[u8; 32], ImageIdError> { + Err(ImageIdError::FeatureNotEnabled) +} + +/// Stub implementation when risc0 feature is not enabled. +#[cfg(not(feature = "risc0"))] +pub fn compute_image_id_from_bytes(_elf_bytes: &[u8]) -> Result<[u8; 32], ImageIdError> { + Err(ImageIdError::FeatureNotEnabled) +} + +/// Returns whether IMAGE_ID computation is available. +/// +/// This returns `true` only if the `risc0` feature is enabled. +pub fn is_available() -> bool { + cfg!(feature = "risc0") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_available() { + // Should return false unless risc0 feature is enabled + #[cfg(not(feature = "risc0"))] + assert!(!is_available()); + + #[cfg(feature = "risc0")] + assert!(is_available()); + } + + #[test] + #[cfg(not(feature = "risc0"))] + fn test_compute_without_feature_returns_error() { + let result = compute_image_id_from_bytes(&[]); + assert!(matches!(result, Err(ImageIdError::FeatureNotEnabled))); + } +} diff --git a/crates/agent-pack/src/lib.rs b/crates/agent-pack/src/lib.rs new file mode 100644 index 0000000..2f4847c --- /dev/null +++ b/crates/agent-pack/src/lib.rs @@ -0,0 +1,52 @@ +//! Agent Pack - Portable bundles for verifiable agent distribution. +//! +//! This crate provides tools for creating and verifying Agent Pack bundles, +//! which are self-contained packages containing all metadata needed to verify +//! an agent's identity and provenance. +//! +//! # Overview +//! +//! An Agent Pack manifest binds together: +//! - Agent metadata (name, version, ID) +//! - Cryptographic commitments (code hash, IMAGE_ID) +//! - Build information for reproducibility +//! - Network deployment addresses +//! +//! # Example +//! +//! ```rust,no_run +//! use agent_pack::{AgentPackManifest, verify_manifest_structure}; +//! +//! // Load a manifest +//! let manifest = AgentPackManifest::from_file("agent-pack.json".as_ref()).unwrap(); +//! +//! // Verify its structure +//! let report = verify_manifest_structure(&manifest); +//! if report.passed { +//! println!("Manifest is valid!"); +//! } else { +//! eprintln!("Verification failed:\n{}", report); +//! } +//! ``` +//! +//! # Features +//! +//! - `risc0` - Enable IMAGE_ID computation from ELF binaries + +pub mod hash; +pub mod image_id; +pub mod manifest; +pub mod verify; + +// Re-export main types at crate root +pub use hash::{format_hex, parse_hex_32, sha256, sha256_file, validate_hex_32, HexError}; +pub use image_id::{compute_image_id_from_bytes, compute_image_id_from_file, ImageIdError}; +pub use manifest::{ + AgentPackManifest, Artifacts, BuildInfo, GitInfo, ManifestError, NetworkConfig, FORMAT_VERSION, +}; +pub use verify::{ + verify_manifest_structure, verify_manifest_with_files, VerificationError, VerificationReport, +}; + +/// Crate version. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/agent-pack/src/manifest.rs b/crates/agent-pack/src/manifest.rs new file mode 100644 index 0000000..e52dc19 --- /dev/null +++ b/crates/agent-pack/src/manifest.rs @@ -0,0 +1,232 @@ +//! Agent Pack manifest types and serialization. +//! +//! Defines the [`AgentPackManifest`] structure that represents a portable, +//! verifiable bundle for distributing agents. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The Agent Pack manifest format version. +pub const FORMAT_VERSION: &str = "1"; + +/// An Agent Pack manifest containing all metadata required for verification. +/// +/// This structure is designed to be: +/// - **Self-contained**: All information needed for offline verification +/// - **Deterministic**: Uses BTreeMap for ordered serialization +/// - **Verifiable**: Contains cryptographic commitments that can be recomputed +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentPackManifest { + /// Format version of this manifest (currently "1") + pub format_version: String, + + /// Human-readable agent name (e.g., "yield-agent") + pub agent_name: String, + + /// Semantic version of the agent (e.g., "0.1.0") + pub agent_version: String, + + /// 32-byte agent identifier as hex string with 0x prefix + pub agent_id: String, + + /// Protocol version this agent targets + pub protocol_version: u32, + + /// Kernel version this agent was built for + pub kernel_version: u32, + + /// RISC Zero zkVM version used for compilation + pub risc0_version: String, + + /// Rust toolchain version used for compilation + pub rust_toolchain: String, + + /// SHA-256 hash of the agent code, as computed by build.rs + /// 32-byte hex string with 0x prefix + pub agent_code_hash: String, + + /// RISC Zero IMAGE_ID computed from the ELF binary + /// 32-byte hex string with 0x prefix + pub image_id: String, + + /// Build artifact information + pub artifacts: Artifacts, + + /// Build configuration and reproducibility info + pub build: BuildInfo, + + /// Human-readable description of expected input format + pub inputs: String, + + /// Human-readable description of actions produced + pub actions_profile: String, + + /// Network-specific deployment addresses + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub networks: BTreeMap, + + /// Git repository information + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + + /// Additional notes or comments + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Build artifact paths and hashes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Artifacts { + /// Relative path to the ELF binary + pub elf_path: String, + + /// SHA-256 hash of the ELF binary + /// 32-byte hex string with 0x prefix + pub elf_sha256: String, +} + +/// Build configuration for reproducibility. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BuildInfo { + /// SHA-256 hash of Cargo.lock + /// 32-byte hex string with 0x prefix + pub cargo_lock_sha256: String, + + /// Command used to build the agent + pub build_command: String, + + /// Whether the build is reproducible (e.g., using Docker) + pub reproducible: bool, +} + +/// Network-specific deployment configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NetworkConfig { + /// Address of the RISC Zero verifier contract + pub verifier: String, + + /// Address of the vault or target contract (if applicable) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vault: Option, +} + +/// Git repository information. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GitInfo { + /// Repository URL + pub repo: String, + + /// Commit hash + pub commit: String, +} + +impl AgentPackManifest { + /// Creates a new manifest with placeholder values for computed fields. + /// + /// Use this when initializing a new manifest template. The following fields + /// will contain "TODO" placeholders that must be computed: + /// - `agent_code_hash` + /// - `image_id` + /// - `artifacts.elf_sha256` + /// - `build.cargo_lock_sha256` + pub fn new_template(name: String, version: String, agent_id: String) -> Self { + let placeholder = "0xTODO_COMPUTE_THIS_VALUE_________________________________________________"; + + Self { + format_version: FORMAT_VERSION.to_string(), + agent_name: name, + agent_version: version, + agent_id, + protocol_version: 1, + kernel_version: 1, + risc0_version: "3.0.4".to_string(), + rust_toolchain: "1.75.0".to_string(), + agent_code_hash: placeholder.to_string(), + image_id: placeholder.to_string(), + artifacts: Artifacts { + elf_path: "artifacts/zkvm-guest.elf".to_string(), + elf_sha256: placeholder.to_string(), + }, + build: BuildInfo { + cargo_lock_sha256: placeholder.to_string(), + build_command: "RISC0_USE_DOCKER=1 cargo build --release -p risc0-methods".to_string(), + reproducible: true, + }, + inputs: "TODO: Describe your agent's input format".to_string(), + actions_profile: "TODO: Describe the actions your agent produces".to_string(), + networks: BTreeMap::new(), + git: None, + notes: None, + } + } + + /// Serializes the manifest to pretty-printed JSON. + pub fn to_json_pretty(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Deserializes a manifest from JSON. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Loads a manifest from a file path. + pub fn from_file(path: &std::path::Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ManifestError::Io(e.to_string()))?; + Self::from_json(&content).map_err(|e| ManifestError::Parse(e.to_string())) + } + + /// Saves the manifest to a file path. + pub fn to_file(&self, path: &std::path::Path) -> Result<(), ManifestError> { + let json = self.to_json_pretty() + .map_err(|e| ManifestError::Serialize(e.to_string()))?; + std::fs::write(path, json).map_err(|e| ManifestError::Io(e.to_string())) + } +} + +/// Errors that can occur when working with manifests. +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("I/O error: {0}")] + Io(String), + + #[error("Failed to parse manifest: {0}")] + Parse(String), + + #[error("Failed to serialize manifest: {0}")] + Serialize(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_manifest_roundtrip() { + let manifest = AgentPackManifest::new_template( + "test-agent".to_string(), + "1.0.0".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000001".to_string(), + ); + + let json = manifest.to_json_pretty().unwrap(); + let parsed = AgentPackManifest::from_json(&json).unwrap(); + + assert_eq!(manifest, parsed); + } + + #[test] + fn test_template_has_placeholders() { + let manifest = AgentPackManifest::new_template( + "test".to_string(), + "1.0.0".to_string(), + "0x42".to_string(), + ); + + assert!(manifest.agent_code_hash.contains("TODO")); + assert!(manifest.image_id.contains("TODO")); + assert!(manifest.artifacts.elf_sha256.contains("TODO")); + assert!(manifest.build.cargo_lock_sha256.contains("TODO")); + } +} diff --git a/crates/agent-pack/src/verify.rs b/crates/agent-pack/src/verify.rs new file mode 100644 index 0000000..8b473bb --- /dev/null +++ b/crates/agent-pack/src/verify.rs @@ -0,0 +1,363 @@ +//! Verification logic for Agent Pack manifests. +//! +//! Provides comprehensive validation of manifest contents, including: +//! - Structure validation (required fields, formats) +//! - Hex string validation (32-byte values with 0x prefix) +//! - Semver validation +//! - Hash verification against actual files + +use crate::hash::{self, validate_hex_32, HexError}; +use crate::manifest::{AgentPackManifest, FORMAT_VERSION}; +use std::path::Path; + +/// Result of manifest verification. +#[derive(Debug)] +pub struct VerificationReport { + /// List of errors found during verification + pub errors: Vec, + /// List of warnings (non-fatal issues) + pub warnings: Vec, + /// Whether all critical checks passed + pub passed: bool, +} + +impl VerificationReport { + fn new() -> Self { + Self { + errors: Vec::new(), + warnings: Vec::new(), + passed: true, + } + } + + fn add_error(&mut self, error: VerificationError) { + self.errors.push(error); + self.passed = false; + } + + fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } +} + +impl std::fmt::Display for VerificationReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.passed { + writeln!(f, "Verification PASSED")?; + } else { + writeln!(f, "Verification FAILED")?; + } + + if !self.errors.is_empty() { + writeln!(f, "\nErrors:")?; + for (i, error) in self.errors.iter().enumerate() { + writeln!(f, " {}. {}", i + 1, error)?; + } + } + + if !self.warnings.is_empty() { + writeln!(f, "\nWarnings:")?; + for (i, warning) in self.warnings.iter().enumerate() { + writeln!(f, " {}. {}", i + 1, warning)?; + } + } + + Ok(()) + } +} + +/// Verification errors. +#[derive(Debug, thiserror::Error)] +pub enum VerificationError { + #[error("invalid format_version: expected '{expected}', got '{actual}'")] + InvalidFormatVersion { expected: String, actual: String }, + + #[error("invalid hex field '{field}': {reason}")] + InvalidHex { field: String, reason: String }, + + #[error("invalid semver '{field}': {value}")] + InvalidSemver { field: String, value: String }, + + #[error("ELF file not found: {path}")] + ElfNotFound { path: String }, + + #[error("ELF hash mismatch: expected {expected}, computed {computed}")] + ElfHashMismatch { expected: String, computed: String }, + + #[error("IMAGE_ID mismatch: expected {expected}, computed {computed}")] + ImageIdMismatch { expected: String, computed: String }, + + #[error("placeholder value found in field '{field}' - run 'agent-pack compute' first")] + PlaceholderFound { field: String }, +} + +/// Verifies a manifest's structure and format. +/// +/// This performs "offline" validation that doesn't require any files: +/// - Format version check +/// - Hex string validation +/// - Semver validation +/// - Placeholder detection +pub fn verify_manifest_structure(manifest: &AgentPackManifest) -> VerificationReport { + let mut report = VerificationReport::new(); + + // Check format version + if manifest.format_version != FORMAT_VERSION { + report.add_error(VerificationError::InvalidFormatVersion { + expected: FORMAT_VERSION.to_string(), + actual: manifest.format_version.clone(), + }); + } + + // Validate hex fields + let hex_fields = [ + ("agent_id", &manifest.agent_id), + ("agent_code_hash", &manifest.agent_code_hash), + ("image_id", &manifest.image_id), + ("artifacts.elf_sha256", &manifest.artifacts.elf_sha256), + ("build.cargo_lock_sha256", &manifest.build.cargo_lock_sha256), + ]; + + for (field, value) in hex_fields { + // Check for placeholder values + if value.contains("TODO") { + report.add_error(VerificationError::PlaceholderFound { + field: field.to_string(), + }); + continue; + } + + // Validate hex format + if let Err(e) = validate_hex_32(value) { + report.add_error(VerificationError::InvalidHex { + field: field.to_string(), + reason: match e { + HexError::MissingPrefix => "missing 0x prefix".to_string(), + HexError::InvalidLength { expected, actual } => { + format!("expected {} hex chars, got {}", expected, actual) + } + HexError::InvalidHex(msg) => msg, + }, + }); + } + } + + // Validate semver + if !is_valid_semver(&manifest.agent_version) { + report.add_error(VerificationError::InvalidSemver { + field: "agent_version".to_string(), + value: manifest.agent_version.clone(), + }); + } + + // Warnings for optional but recommended fields + if manifest.git.is_none() { + report.add_warning("git info not provided - recommended for traceability".to_string()); + } + + if manifest.networks.is_empty() { + report.add_warning("no network deployments specified".to_string()); + } + + report +} + +/// Verifies a manifest against actual files. +/// +/// In addition to structure verification, this: +/// - Verifies ELF file exists and matches declared hash +/// - Recomputes IMAGE_ID (if risc0 feature enabled) +/// +/// # Arguments +/// +/// * `manifest` - The manifest to verify +/// * `base_path` - Base directory for resolving relative paths in the manifest +pub fn verify_manifest_with_files( + manifest: &AgentPackManifest, + base_path: &Path, +) -> VerificationReport { + let mut report = verify_manifest_structure(manifest); + + // Skip file verification if structure validation failed + if !report.passed { + return report; + } + + // Resolve ELF path + let elf_path = base_path.join(&manifest.artifacts.elf_path); + + if !elf_path.exists() { + report.add_error(VerificationError::ElfNotFound { + path: elf_path.display().to_string(), + }); + return report; + } + + // Verify ELF hash + match hash::sha256_file(&elf_path) { + Ok(computed_hash) => { + let computed_hex = hash::format_hex(&computed_hash); + if computed_hex != manifest.artifacts.elf_sha256 { + report.add_error(VerificationError::ElfHashMismatch { + expected: manifest.artifacts.elf_sha256.clone(), + computed: computed_hex, + }); + } + } + Err(e) => { + report.add_warning(format!("Could not read ELF file for hash verification: {}", e)); + } + } + + // Verify IMAGE_ID if risc0 feature is enabled + #[cfg(feature = "risc0")] + { + use crate::image_id::compute_image_id_from_file; + + match compute_image_id_from_file(&elf_path) { + Ok(computed_id) => { + let computed_hex = hash::format_hex(&computed_id); + if computed_hex != manifest.image_id { + report.add_error(VerificationError::ImageIdMismatch { + expected: manifest.image_id.clone(), + computed: computed_hex, + }); + } + } + Err(e) => { + report.add_warning(format!("Could not compute IMAGE_ID: {}", e)); + } + } + } + + #[cfg(not(feature = "risc0"))] + { + report.add_warning( + "IMAGE_ID verification skipped - build with --features risc0 to enable".to_string(), + ); + } + + report +} + +/// Simple semver validation. +/// +/// Accepts versions like "1.0.0", "0.1.0-alpha", "2.0.0-rc.1+build.123" +fn is_valid_semver(version: &str) -> bool { + // Basic format: MAJOR.MINOR.PATCH with optional prerelease and build metadata + let parts: Vec<&str> = version.split(|c| c == '-' || c == '+').collect(); + + if parts.is_empty() { + return false; + } + + // Check MAJOR.MINOR.PATCH + let version_core: Vec<&str> = parts[0].split('.').collect(); + if version_core.len() != 3 { + return false; + } + + // Each part must be a valid number + for part in version_core { + if part.is_empty() || part.parse::().is_err() { + return false; + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::AgentPackManifest; + + fn valid_manifest() -> AgentPackManifest { + AgentPackManifest { + format_version: "1".to_string(), + agent_name: "test-agent".to_string(), + agent_version: "1.0.0".to_string(), + agent_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + protocol_version: 1, + kernel_version: 1, + risc0_version: "3.0.4".to_string(), + rust_toolchain: "1.75.0".to_string(), + agent_code_hash: + "0x5aac6b1fedf1b0c0ccc037c3223b7b5c8b679f48b9c599336c0dc777be88924b" + .to_string(), + image_id: "0x5f42241afd61bf9e341442c8baffa9c544cf20253720f2540cf6705f27bae2c4" + .to_string(), + artifacts: crate::manifest::Artifacts { + elf_path: "artifacts/zkvm-guest.elf".to_string(), + elf_sha256: + "0xabcdef0000000000000000000000000000000000000000000000000000000123" + .to_string(), + }, + build: crate::manifest::BuildInfo { + cargo_lock_sha256: + "0x1234560000000000000000000000000000000000000000000000000000000abc" + .to_string(), + build_command: "cargo build --release".to_string(), + reproducible: true, + }, + inputs: "Test input".to_string(), + actions_profile: "Test actions".to_string(), + networks: std::collections::BTreeMap::new(), + git: None, + notes: None, + } + } + + #[test] + fn test_valid_manifest_passes() { + let manifest = valid_manifest(); + let report = verify_manifest_structure(&manifest); + assert!(report.passed, "Report: {}", report); + } + + #[test] + fn test_invalid_format_version() { + let mut manifest = valid_manifest(); + manifest.format_version = "2".to_string(); + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + assert!(report.errors.iter().any(|e| matches!(e, VerificationError::InvalidFormatVersion { .. }))); + } + + #[test] + fn test_invalid_hex_missing_prefix() { + let mut manifest = valid_manifest(); + manifest.agent_id = "0000000000000000000000000000000000000000000000000000000000000001".to_string(); + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + } + + #[test] + fn test_placeholder_detected() { + let mut manifest = valid_manifest(); + manifest.image_id = "0xTODO_COMPUTE_THIS".to_string(); + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + assert!(report.errors.iter().any(|e| matches!(e, VerificationError::PlaceholderFound { .. }))); + } + + #[test] + fn test_valid_semver() { + assert!(is_valid_semver("1.0.0")); + assert!(is_valid_semver("0.1.0")); + assert!(is_valid_semver("10.20.30")); + assert!(is_valid_semver("1.0.0-alpha")); + assert!(is_valid_semver("1.0.0-rc.1")); + assert!(is_valid_semver("1.0.0+build.123")); + } + + #[test] + fn test_invalid_semver() { + assert!(!is_valid_semver("1.0")); + assert!(!is_valid_semver("1")); + assert!(!is_valid_semver("")); + assert!(!is_valid_semver("v1.0.0")); + assert!(!is_valid_semver("1.0.0.0")); + } +} diff --git a/crates/agent-pack/tests/hex_validation.rs b/crates/agent-pack/tests/hex_validation.rs new file mode 100644 index 0000000..c9aa22b --- /dev/null +++ b/crates/agent-pack/tests/hex_validation.rs @@ -0,0 +1,92 @@ +//! Tests for hex string validation. + +use agent_pack::{parse_hex_32, validate_hex_32, HexError}; + +#[test] +fn test_valid_hex_32_bytes() { + let valid = "0x0000000000000000000000000000000000000000000000000000000000000001"; + assert!(validate_hex_32(valid).is_ok()); + + let parsed = parse_hex_32(valid).unwrap(); + assert_eq!(parsed[31], 1); + assert_eq!(parsed[0], 0); +} + +#[test] +fn test_valid_hex_all_fs() { + let valid = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + assert!(validate_hex_32(valid).is_ok()); + + let parsed = parse_hex_32(valid).unwrap(); + assert!(parsed.iter().all(|&b| b == 0xff)); +} + +#[test] +fn test_invalid_hex_wrong_length_too_short() { + let too_short = "0x0001"; + let result = validate_hex_32(too_short); + assert!(matches!( + result, + Err(HexError::InvalidLength { + expected: 64, + actual: 4 + }) + )); +} + +#[test] +fn test_invalid_hex_wrong_length_too_long() { + let too_long = "0x00000000000000000000000000000000000000000000000000000000000000010000"; + let result = validate_hex_32(too_long); + assert!(matches!(result, Err(HexError::InvalidLength { .. }))); +} + +#[test] +fn test_invalid_hex_no_prefix() { + let no_prefix = "0000000000000000000000000000000000000000000000000000000000000001"; + let result = validate_hex_32(no_prefix); + assert_eq!(result, Err(HexError::MissingPrefix)); +} + +#[test] +fn test_invalid_hex_bad_chars() { + let bad_chars = "0xGGGG000000000000000000000000000000000000000000000000000000000001"; + let result = validate_hex_32(bad_chars); + assert!(matches!(result, Err(HexError::InvalidHex(_)))); +} + +#[test] +fn test_invalid_hex_uppercase_valid() { + // Uppercase hex should be valid + let uppercase = "0xABCDEF0000000000000000000000000000000000000000000000000000000001"; + assert!(validate_hex_32(uppercase).is_ok()); +} + +#[test] +fn test_invalid_hex_mixed_case_valid() { + let mixed = "0xAbCdEf0000000000000000000000000000000000000000000000000000000001"; + assert!(validate_hex_32(mixed).is_ok()); +} + +#[test] +fn test_parse_hex_without_prefix() { + // parse_hex_32 accepts without prefix + let no_prefix = "0000000000000000000000000000000000000000000000000000000000000001"; + let result = parse_hex_32(no_prefix); + assert!(result.is_ok()); + assert_eq!(result.unwrap()[31], 1); +} + +#[test] +fn test_image_id_format() { + // Known IMAGE_ID from yield agent + let image_id = "0x5f42241afd61bf9e341442c8baffa9c544cf20253720f2540cf6705f27bae2c4"; + assert!(validate_hex_32(image_id).is_ok()); +} + +#[test] +fn test_agent_code_hash_format() { + // Known agent code hash from yield agent + let code_hash = "0x5aac6b1fedf1b0c0ccc037c3223b7b5c8b679f48b9c599336c0dc777be88924b"; + assert!(validate_hex_32(code_hash).is_ok()); +} diff --git a/crates/agent-pack/tests/manifest_tests.rs b/crates/agent-pack/tests/manifest_tests.rs new file mode 100644 index 0000000..27666fb --- /dev/null +++ b/crates/agent-pack/tests/manifest_tests.rs @@ -0,0 +1,246 @@ +//! Tests for manifest parsing and serialization. + +use agent_pack::{verify_manifest_structure, AgentPackManifest, VerificationError}; +use std::collections::BTreeMap; + +fn create_valid_manifest() -> AgentPackManifest { + AgentPackManifest { + format_version: "1".to_string(), + agent_name: "test-agent".to_string(), + agent_version: "1.0.0".to_string(), + agent_id: "0x0000000000000000000000000000000000000000000000000000000000000001" + .to_string(), + protocol_version: 1, + kernel_version: 1, + risc0_version: "3.0.4".to_string(), + rust_toolchain: "1.75.0".to_string(), + agent_code_hash: "0x5aac6b1fedf1b0c0ccc037c3223b7b5c8b679f48b9c599336c0dc777be88924b" + .to_string(), + image_id: "0x5f42241afd61bf9e341442c8baffa9c544cf20253720f2540cf6705f27bae2c4" + .to_string(), + artifacts: agent_pack::Artifacts { + elf_path: "artifacts/zkvm-guest.elf".to_string(), + elf_sha256: "0xabcdef0000000000000000000000000000000000000000000000000000000123" + .to_string(), + }, + build: agent_pack::BuildInfo { + cargo_lock_sha256: "0x1234560000000000000000000000000000000000000000000000000000000abc" + .to_string(), + build_command: "RISC0_USE_DOCKER=1 cargo build --release".to_string(), + reproducible: true, + }, + inputs: "48-byte payload: vault(20) || yield_source(20) || amount(8)".to_string(), + actions_profile: "Produces 2 CALL actions".to_string(), + networks: BTreeMap::new(), + git: Some(agent_pack::GitInfo { + repo: "https://github.com/Defiesta/execution-kernel".to_string(), + commit: "ed3ee50".to_string(), + }), + notes: Some("Test agent".to_string()), + } +} + +#[test] +fn test_manifest_roundtrip() { + let manifest = create_valid_manifest(); + + // Serialize to JSON + let json = manifest.to_json_pretty().unwrap(); + + // Deserialize back + let parsed = AgentPackManifest::from_json(&json).unwrap(); + + // Should be identical + assert_eq!(manifest, parsed); +} + +#[test] +fn test_manifest_validation_passes() { + let manifest = create_valid_manifest(); + let report = verify_manifest_structure(&manifest); + assert!(report.passed, "Report should pass: {}", report); +} + +#[test] +fn test_manifest_validation_invalid_format_version() { + let mut manifest = create_valid_manifest(); + manifest.format_version = "2".to_string(); + + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + assert!(report + .errors + .iter() + .any(|e| matches!(e, VerificationError::InvalidFormatVersion { .. }))); +} + +#[test] +fn test_manifest_validation_detects_placeholder() { + let mut manifest = create_valid_manifest(); + manifest.image_id = "0xTODO_COMPUTE_THIS_VALUE".to_string(); + + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + assert!(report + .errors + .iter() + .any(|e| matches!(e, VerificationError::PlaceholderFound { field } if field == "image_id"))); +} + +#[test] +fn test_manifest_validation_invalid_hex() { + let mut manifest = create_valid_manifest(); + manifest.agent_id = "0xnothex".to_string(); + + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + assert!(report + .errors + .iter() + .any(|e| matches!(e, VerificationError::InvalidHex { field, .. } if field == "agent_id"))); +} + +#[test] +fn test_manifest_validation_missing_prefix() { + let mut manifest = create_valid_manifest(); + manifest.agent_id = + "0000000000000000000000000000000000000000000000000000000000000001".to_string(); + + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); +} + +#[test] +fn test_semver_validation_valid() { + let manifest = create_valid_manifest(); + let report = verify_manifest_structure(&manifest); + assert!(report.passed); +} + +#[test] +fn test_semver_validation_invalid() { + let mut manifest = create_valid_manifest(); + manifest.agent_version = "1.0".to_string(); // Missing patch version + + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + assert!(report + .errors + .iter() + .any(|e| matches!(e, VerificationError::InvalidSemver { .. }))); +} + +#[test] +fn test_semver_validation_prerelease() { + let mut manifest = create_valid_manifest(); + manifest.agent_version = "1.0.0-alpha.1".to_string(); + + let report = verify_manifest_structure(&manifest); + assert!(report.passed); +} + +#[test] +fn test_semver_validation_build_metadata() { + let mut manifest = create_valid_manifest(); + manifest.agent_version = "1.0.0+build.123".to_string(); + + let report = verify_manifest_structure(&manifest); + assert!(report.passed); +} + +#[test] +fn test_manifest_template_has_placeholders() { + let manifest = AgentPackManifest::new_template( + "test".to_string(), + "1.0.0".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000042".to_string(), + ); + + // Template should fail validation due to placeholders + let report = verify_manifest_structure(&manifest); + assert!(!report.passed); + + // Should have placeholder errors for computed fields + let placeholder_fields: Vec<&str> = report + .errors + .iter() + .filter_map(|e| { + if let VerificationError::PlaceholderFound { field } = e { + Some(field.as_str()) + } else { + None + } + }) + .collect(); + + assert!(placeholder_fields.contains(&"agent_code_hash")); + assert!(placeholder_fields.contains(&"image_id")); + assert!(placeholder_fields.contains(&"artifacts.elf_sha256")); + assert!(placeholder_fields.contains(&"build.cargo_lock_sha256")); +} + +#[test] +fn test_manifest_json_structure() { + let manifest = create_valid_manifest(); + let json = manifest.to_json_pretty().unwrap(); + + // Verify JSON structure contains expected fields + assert!(json.contains("\"format_version\"")); + assert!(json.contains("\"agent_name\"")); + assert!(json.contains("\"agent_id\"")); + assert!(json.contains("\"image_id\"")); + assert!(json.contains("\"artifacts\"")); + assert!(json.contains("\"build\"")); +} + +#[test] +fn test_manifest_networks_serialization() { + let mut manifest = create_valid_manifest(); + manifest.networks.insert( + "sepolia".to_string(), + agent_pack::NetworkConfig { + verifier: "0x9Ef5bAB590AFdE8036D57b89ccD2947D4E3b1EFA".to_string(), + vault: Some("0xAdeDA97D2D07C7f2e332fD58F40Eb4f7F0192be7".to_string()), + }, + ); + + let json = manifest.to_json_pretty().unwrap(); + assert!(json.contains("\"sepolia\"")); + assert!(json.contains("\"verifier\"")); + + // Roundtrip + let parsed = AgentPackManifest::from_json(&json).unwrap(); + assert_eq!(parsed.networks.len(), 1); + assert!(parsed.networks.contains_key("sepolia")); +} + +#[test] +fn test_manifest_empty_networks_not_serialized() { + let manifest = create_valid_manifest(); + let json = manifest.to_json_pretty().unwrap(); + + // Empty networks should be skipped due to skip_serializing_if + // The field might still appear as {} or not at all depending on serde behavior + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + if let Some(networks) = parsed.get("networks") { + assert!(networks.as_object().map_or(true, |o| o.is_empty())); + } +} + +#[test] +fn test_manifest_file_operations() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let path = dir.path().join("test-manifest.json"); + + let manifest = create_valid_manifest(); + + // Write to file + manifest.to_file(&path).unwrap(); + + // Read back + let loaded = AgentPackManifest::from_file(&path).unwrap(); + + assert_eq!(manifest, loaded); +} diff --git a/dist/agent-pack.example.json b/dist/agent-pack.example.json new file mode 100644 index 0000000..9df4a54 --- /dev/null +++ b/dist/agent-pack.example.json @@ -0,0 +1,34 @@ +{ + "format_version": "1", + "agent_name": "yield-agent", + "agent_version": "0.1.0", + "agent_id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "protocol_version": 1, + "kernel_version": 1, + "risc0_version": "3.0.4", + "rust_toolchain": "1.75.0", + "agent_code_hash": "0x5aac6b1fedf1b0c0ccc037c3223b7b5c8b679f48b9c599336c0dc777be88924b", + "image_id": "0x5f42241afd61bf9e341442c8baffa9c544cf20253720f2540cf6705f27bae2c4", + "artifacts": { + "elf_path": "artifacts/zkvm-guest.elf", + "elf_sha256": "0xa1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + }, + "build": { + "cargo_lock_sha256": "0xf1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2", + "build_command": "RISC0_USE_DOCKER=1 cargo build --release -p risc0-methods", + "reproducible": true + }, + "inputs": "48-byte payload: vault(20) || yield_source(20) || amount(8)", + "actions_profile": "Produces 2 CALL actions: deposit then withdraw", + "networks": { + "sepolia": { + "verifier": "0x9Ef5bAB590AFdE8036D57b89ccD2947D4E3b1EFA", + "vault": "0xAdeDA97D2D07C7f2e332fD58F40Eb4f7F0192be7" + } + }, + "git": { + "repo": "https://github.com/Defiesta/execution-kernel", + "commit": "ed3ee50" + }, + "notes": "Example yield agent for testing purposes" +} diff --git a/docs/P0.1_DOCUMENTATION.md b/docs/P0.1_DOCUMENTATION.md deleted file mode 100644 index fcd1eff..0000000 --- a/docs/P0.1_DOCUMENTATION.md +++ /dev/null @@ -1,130 +0,0 @@ -# Execution Kernel — Developer Documentation (P0.1) - -## Introduction - -The execution kernel is the core of the protocol. It is the component that defines what it means for an off-chain agent execution to be considered valid, provable, and safe to settle on-chain. Everything else in the system exists to support, invoke, or verify the kernel. If the kernel behaves correctly, the protocol works. If it does not, no amount of surrounding infrastructure can compensate for it. - -As a developer, you should think of the kernel as consensus code rather than application code. Any change that could cause two honest provers to produce different output bytes for the same input is a protocol change. This document explains how the kernel works, why it is structured the way it is, and how you are expected to use it. - - - -## What the kernel does - -The kernel takes a fully specified input, executes an agent deterministically inside a zkVM, enforces protocol constraints, and produces a cryptographic journal. That journal is the only artifact that leaves the zkVM and is the only thing that on-chain contracts or off-chain verifiers need to interpret. - -From a usage perspective, the kernel answers one question and one question only: given these exact input bytes, did this exact agent code execute correctly under these exact rules, and if so, what actions did it produce? - - - -## Determinism and canonical behavior - -Determinism is the most important property of the kernel. Given the same input bytes, the kernel must always follow the same execution path and must always produce the same output bytes. This must hold across different machines, different provers, and different builds, as long as the toolchain is pinned. - -Canonical behavior is how determinism is enforced. For every protocol-relevant concept, such as inputs, outputs, ordering, and hashing, the kernel defines exactly one valid representation. There are no alternative encodings, no equivalent orderings, and no tolerated ambiguities. If two implementations disagree on a single byte, they are not both correct. - - - -## Kernel input and how to construct it - -The kernel input is not just a set of parameters. It is a binding statement that defines exactly what is being proven. When you construct a kernel input, you are committing to a specific protocol version, a specific kernel implementation, a specific agent and agent binary, a specific constraint policy, a specific external state snapshot, and a specific execution instance. - -The input is represented by the KernelInputV1 structure. It includes version fields, identifiers, cryptographic hashes, a replay-protection nonce, and an opaque byte array that is passed verbatim to the agent. The kernel does not interpret the opaque agent input. It only enforces size limits and canonical encoding rules. - -When encoding a kernel input, you must follow the canonical little-endian encoding defined by the kernel. Length prefixes are mandatory, size limits are enforced, and any extra or trailing bytes will cause decoding to fail. If decoding fails inside the kernel, execution aborts and no proof can be produced. - - - -## Input commitment and why it matters - -After decoding the input, the kernel computes an input commitment by hashing the full input byte sequence with SHA-256. This hash is taken over the exact bytes that were provided to the kernel, without re-encoding or normalization. - -This commitment ensures that the proof is bound to the precise input that was executed. It prevents operators from reinterpreting inputs after the fact and allows on-chain logic to reason about what the agent actually observed. When you use the kernel, you should always treat the input commitment as the authoritative fingerprint of the execution context. - - -## Agent execution model - -Agents are executed as pure, deterministic functions. An agent receives an execution context and a byte array of opaque inputs and produces a structured output. Agents have no access to time, randomness, I/O, or any external state beyond what is explicitly provided to them. - -The kernel always calls the agent. The agent never calls back into the kernel. This asymmetry is intentional and security-critical. It ensures that agents cannot bypass constraint checks, cannot influence commitments, and cannot affect encoding or ordering rules. - -From a developer perspective, writing an agent means implementing logic that proposes actions, not logic that enforces safety. Safety is always the kernel’s responsibility. - - -## Agent output and action structure - -An agent produces an AgentOutput, which is a collection of structured actions. Each action includes an action type identifier, a target identifier, and an opaque payload. The kernel enforces strict limits on the number of actions and the size of each payload. - -These limits are enforced both when encoding and when validating the output structure. Even if an agent attempts to produce an oversized or malformed output, the kernel will detect it and abort execution. - - -## Canonical action ordering - -Agents are not trusted to produce actions in a consistent or meaningful order. To avoid ambiguity, the kernel enforces canonical ordering during encoding. Actions are sorted deterministically by action type, then by target, and finally by payload bytes. - -This sorting happens inside the encoding logic itself. As a result, it is impossible to accidentally compute a commitment over a non-canonical ordering. This guarantees that all honest provers will compute identical action commitments, even if agents emit actions in arbitrary order. - -As a user of the kernel, you should assume that action order is normalized and should never rely on the original order in which an agent produced actions. - - -## Constraint enforcement - -Constraint enforcement is mandatory. Every kernel execution validates the agent output structure and then applies the constraint policy associated with the provided constraint set hash. - -In P0.1, the constraint logic was intentionally a stub that always succeeds. This locked in the protocol structure. As of P0.3, real constraint enforcement is implemented, validating action types, payload schemas, asset whitelists, position limits, leverage limits, cooldown periods, and drawdown limits. - -If any constraint check fails in P0.3+, the kernel produces a `Failure` journal with an empty action commitment. The proof remains valid, but verifiers should reject the state transition. See `docs/P0.3_DOCUMENTATION.md` and `spec/constraints.md` for full constraint semantics. - - -## Action commitment - -After canonicalizing and encoding the agent output, the kernel computes an action commitment by hashing the encoded bytes with SHA-256. This commitment binds the proof to the exact set of actions produced by the agent under canonical ordering. - -On-chain settlement logic should treat the action commitment as the authoritative representation of what the agent decided to do. - - -## Kernel journal and how to use it - -The kernel journal is the only output of a successful execution. It is a fixed-size, deterministic structure that contains all information needed for verification and settlement. It includes version fields, identity bindings, the execution nonce, the input commitment, the action commitment, and a success status. - -The journal is committed to the zkVM environment only if execution succeeds. If execution fails for any reason, no journal bytes are committed and no valid proof can exist. - -When you build on top of the kernel, your on-chain or off-chain verifier should decode the journal, validate its versions, and use its commitments to authorize or reject actions. - - -## Failure semantics - -The kernel distinguishes between two types of failures: - -**Hard failures** (decoding errors, version mismatches, encoding errors): No journal is produced. In the zkVM guest, these cause a panic which prevents any journal from being committed. - -**Constraint violations** (P0.3+): A `Failure` journal is produced with `execution_status = 0x02` and an empty action commitment. The proof is valid, but verifiers should reject the state transition. This allows constraint violations to be provable on-chain. - -In P0.1, all failures resulted in no journal. As of P0.3, constraint violations are auditable through failure journals. - - -## zkVM integration - -The kernel itself is independent of the zkVM. It operates on raw byte slices and returns raw byte vectors. The zkVM guest wrapper is intentionally thin and exists only to read input bytes, invoke the kernel, and commit the resulting journal bytes. - -As a developer, you should avoid placing logic in the guest wrapper. All protocol semantics must live in the kernel core. - - -## Versioning and upgrades - -The kernel tracks both a protocol version and a kernel version. The protocol version governs wire format compatibility, while the kernel version governs execution semantics. Any change to encoding rules, execution logic, or ordering rules requires a kernel version bump. - -Old versions are never reinterpreted. A verifier must always know exactly which kernel semantics a proof corresponds to. - - -## How to use the kernel correctly - -To use the kernel correctly, you should construct a canonical KernelInputV1, encode it using the provided encoding rules, and pass the resulting bytes to the zkVM guest. You should never attempt to partially construct journals or commitments yourself. All commitments must be computed by the kernel. - -On the verification side, you should treat the kernel journal as the single source of truth. Never trust off-chain claims about what an agent did without validating the journal and its commitments. - - -## Final note for developers - -When working on this codebase, always ask yourself whether a change could affect determinism, encoding, ordering, or commitment computation. If the answer is yes, the change is protocol-critical and must be treated as such. - -The kernel is not just another library. It is the definition of the protocol itself. \ No newline at end of file diff --git a/docs/P0.2_DOCUMENTATION.md b/docs/P0.2_DOCUMENTATION.md deleted file mode 100644 index 1d87149..0000000 --- a/docs/P0.2_DOCUMENTATION.md +++ /dev/null @@ -1,290 +0,0 @@ -# Canonical Codec and Commitment Specification - -This document specifies the canonical binary encoding for the kernel protocol (version 1). -All encodings are deterministic and consensus-critical. - -## Design Principles - -1. **Determinism**: Identical data structures always produce identical byte sequences -2. **Self-describing lengths**: Variable-length fields are prefixed with their byte length -3. **Little-endian integers**: All multi-byte integers use little-endian byte order -4. **No implicit padding**: Structures are tightly packed with no alignment padding -5. **Strict decoding**: Trailing bytes, invalid versions, and out-of-range values are rejected - ---- - -## Primitive Encoding Rules - -### Integers - -| Type | Encoding | Size | -|------|----------|------| -| `u32` | Little-endian | 4 bytes | -| `u64` | Little-endian | 8 bytes | -| `u8` | Raw byte | 1 byte | - -### Fixed-Size Byte Arrays - -| Type | Encoding | Size | -|------|----------|------| -| `[u8; 32]` | Raw bytes (no length prefix) | 32 bytes | - -### Variable-Length Byte Arrays - -| Type | Encoding | Size | -|------|----------|------| -| `Vec` | `[length: u32][data: u8*]` | 4 + length bytes | - -The `length` field specifies the **number of bytes** in the data that follows. - ---- - -## KernelInputV1 - -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `protocol_version` | u32 | 4 | Wire format version | -| 4 | `kernel_version` | u32 | 4 | Kernel semantics version | -| 8 | `agent_id` | [u8; 32] | 32 | Agent identifier | -| 40 | `agent_code_hash` | [u8; 32] | 32 | SHA-256 of agent binary | -| 72 | `constraint_set_hash` | [u8; 32] | 32 | SHA-256 of constraint set | -| 104 | `input_root` | [u8; 32] | 32 | External state root (market/vault snapshot) | -| 136 | `execution_nonce` | u64 | 8 | Replay protection nonce | -| 144 | `opaque_agent_inputs_len` | u32 | 4 | Length of agent input data (bytes) | -| 148 | `opaque_agent_inputs` | [u8; *] | variable | Agent-specific input (max 64,000 bytes) | - -### Size - -- **Fixed header**: 148 bytes -- **Total**: 148 + `opaque_agent_inputs_len` bytes -- **Minimum**: 148 bytes (empty input) -- **Maximum**: 148 + 64,000 = 64,148 bytes - -### Validation Rules - -1. `protocol_version` MUST equal `PROTOCOL_VERSION` (currently 1) -2. `kernel_version` MUST equal `KERNEL_VERSION` (currently 1) -3. `opaque_agent_inputs_len` MUST NOT exceed `MAX_AGENT_INPUT_BYTES` (64,000) -4. Total bytes MUST equal exactly 148 + `opaque_agent_inputs_len` (no trailing bytes) -5. Decoders MUST reject if `148 + opaque_agent_inputs_len` would overflow - ---- - -## KernelJournalV1 - -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `protocol_version` | u32 | 4 | Wire format version | -| 4 | `kernel_version` | u32 | 4 | Kernel semantics version | -| 8 | `agent_id` | [u8; 32] | 32 | Agent identifier (copied from input) | -| 40 | `agent_code_hash` | [u8; 32] | 32 | Agent code hash (copied from input) | -| 72 | `constraint_set_hash` | [u8; 32] | 32 | Constraint set hash (copied from input) | -| 104 | `input_root` | [u8; 32] | 32 | External state root (copied from input) | -| 136 | `execution_nonce` | u64 | 8 | Execution nonce (copied from input) | -| 144 | `input_commitment` | [u8; 32] | 32 | SHA-256 of encoded KernelInputV1 | -| 176 | `action_commitment` | [u8; 32] | 32 | SHA-256 of encoded AgentOutput | -| 208 | `execution_status` | u8 | 1 | Execution result | - -### Size - -- **Fixed**: 209 bytes (always) - -### Validation Rules - -1. `protocol_version` MUST equal `PROTOCOL_VERSION` (currently 1) -2. `kernel_version` MUST equal `KERNEL_VERSION` (currently 1) -3. `execution_status` MUST equal 0x01 (Success) or 0x02 (Failure) -4. Total bytes MUST equal exactly 209 (no more, no less) - ---- - -## ExecutionStatus - -### Encoding - -| Value | Status | Description | -|-------|--------|-------------| -| 0x00 | Reserved | Invalid (catches uninitialized memory) | -| 0x01 | Success | Execution completed successfully | -| 0x02 | Failure | Constraint violation (P0.3+) | -| 0x03-0xFF | Reserved | Invalid (reserved for future expansion) | - -### Rationale - -The value 0x00 is explicitly reserved to detect bugs where uninitialized memory is accidentally interpreted as a valid status. - -**P0.1-P0.2:** Only 0x01 (Success) was valid. Failures caused the kernel to abort before committing a journal. - -**P0.3+:** Both 0x01 (Success) and 0x02 (Failure) are valid. Constraint violations produce a Failure journal with an empty action commitment. This allows constraint violations to be provable on-chain while still rejecting invalid state transitions. - -Decoders MUST reject any value other than 0x01 or 0x02. - ---- - -## ActionV1 - -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `action_type` | u32 | 4 | Action type identifier | -| 4 | `target` | [u8; 32] | 32 | Target address/identifier | -| 36 | `payload_len` | u32 | 4 | Length of payload data (bytes) | -| 40 | `payload` | [u8; *] | variable | Action-specific data (max 16,384 bytes) | - -### Size - -- **Fixed header**: 40 bytes -- **Total**: 40 + `payload_len` bytes -- **Minimum**: 40 bytes (empty payload) -- **Maximum**: 40 + 16,384 = 16,424 bytes (`MAX_SINGLE_ACTION_BYTES`) - -### Validation Rules - -1. `payload_len` MUST NOT exceed `MAX_ACTION_PAYLOAD_BYTES` (16,384) -2. Total bytes MUST equal exactly 40 + `payload_len` (no trailing bytes) -3. When embedded in AgentOutput, the `action_len` prefix MUST exactly equal the actual byte length of the ActionV1 encoding (i.e., `action_len == 40 + payload_len`) - ---- - -## AgentOutput - -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `action_count` | u32 | 4 | Number of actions | -| 4 | actions[0..n] | ActionV1[] | variable | Length-prefixed actions | - -Each action is encoded as: -- `action_len: u32` (4 bytes) - Byte length of the following ActionV1 encoding -- `action: ActionV1` (variable) - The encoded action - -### Size - -- **Minimum**: 4 bytes (zero actions) -- **Maximum**: computed as follows: - - Per-action overhead: `action_len` prefix (4) + `MAX_SINGLE_ACTION_BYTES` (16,424) = 16,428 bytes - - Total: 4 + `MAX_ACTIONS_PER_OUTPUT` × 16,428 = 4 + 64 × 16,428 = **1,051,396 bytes** - -### Validation Rules - -1. `action_count` MUST NOT exceed `MAX_ACTIONS_PER_OUTPUT` (64) -2. Each `action_len` MUST NOT exceed `MAX_SINGLE_ACTION_BYTES` (16,424) -3. Each `action_len` MUST exactly equal the number of bytes consumed by the following ActionV1 encoding -4. Exactly `action_count` actions MUST be present; fewer bytes implies `UnexpectedEndOfInput`, more bytes implies `InvalidLength` -5. Total bytes MUST equal the sum of all prefixes and action encodings (no trailing bytes) - ---- - -## Canonical Ordering - -Actions MUST be sorted into canonical order before encoding. This ensures deterministic `action_commitment` regardless of the order agents produce actions. - -### Ordering Rules - -Actions are sorted using lexicographic comparison in this priority: - -1. `action_type` (ascending, unsigned integer comparison) -2. `target` (lexicographic byte comparison, 32 bytes) -3. `payload` (lexicographic byte comparison of raw payload bytes) - -**Important**: The `payload_len` field is **not** part of the sort key; only the raw payload bytes are compared. Actions with identical `action_type`, `target`, and `payload` bytes are considered equal regardless of encoding. - -### Example - -Given actions: -- A: `{type: 2, target: 0x11..., payload: [1]}` -- B: `{type: 1, target: 0x22..., payload: [2]}` -- C: `{type: 1, target: 0x11..., payload: [3]}` - -Canonical order: **C, B, A** - -Reasoning: -1. C and B have `action_type=1`, A has `action_type=2` → A comes last -2. Between C and B: C has `target=0x11...`, B has `target=0x22...` → C comes first (0x11 < 0x22) - ---- - -## Commitment Computation - -### Input Commitment - -``` -input_commitment = SHA-256(encoded_KernelInputV1) -``` - -The commitment is computed over the **complete canonical encoding** of KernelInputV1, including: -- All fixed header fields (148 bytes) -- The `opaque_agent_inputs_len` length prefix (4 bytes) -- The `opaque_agent_inputs` data bytes - -### Action Commitment - -``` -action_commitment = SHA-256(encoded_AgentOutput) -``` - -The commitment is computed over the **complete canonical encoding** of AgentOutput, including: -- The `action_count` field (4 bytes) -- All `action_len` prefixes and ActionV1 encodings -- Actions MUST be in canonical order (see Canonical Ordering) - ---- - -## Error Handling - -### Codec Errors - -| Error | Condition | -|-------|-----------| -| `UnexpectedEndOfInput` | Insufficient bytes to decode a field | -| `InvalidLength` | Trailing bytes after complete structure, or length mismatch | -| `InvalidVersion` | Protocol or kernel version does not match expected constant | -| `InputTooLarge` | `opaque_agent_inputs_len` > 64,000 | -| `ActionPayloadTooLarge` | `payload_len` > 16,384 | -| `TooManyActions` | `action_count` > 64 | -| `ActionTooLarge` | Individual action encoding > 16,424 bytes | -| `InvalidExecutionStatus` | Status byte is not 0x01 or 0x02 | -| `ArithmeticOverflow` | Length calculation (e.g., offset + field_len) would overflow | - -### Strict Decoding - -Decoders MUST reject: -- Inputs with trailing bytes beyond the expected structure size -- Unknown or unsupported version numbers (protocol_version or kernel_version) -- Out-of-range size values (exceeding defined maximums) -- Invalid enumeration values (execution_status not in {0x01, 0x02}) -- Any computation where `offset + field_len` would overflow `usize` - ---- - -## Constants - -| Constant | Value | Description | -|----------|-------|-------------| -| `PROTOCOL_VERSION` | 1 | Current protocol version | -| `KERNEL_VERSION` | 1 | Current kernel version | -| `MAX_AGENT_INPUT_BYTES` | 64,000 | Maximum agent input size (bytes) | -| `MAX_ACTION_PAYLOAD_BYTES` | 16,384 | Maximum action payload size (bytes) | -| `MAX_ACTIONS_PER_OUTPUT` | 64 | Maximum actions per output | -| `MAX_SINGLE_ACTION_BYTES` | 16,424 | Maximum encoded ActionV1 size (40 + 16,384) | -| `JOURNAL_SIZE` | 209 | Fixed KernelJournalV1 size (bytes) | - -**Note**: `MAX_AGENT_INPUT_BYTES` is 64,000 bytes, not 64 KiB (65,536). This is an intentional limit. - ---- - -## Test Vectors - -See `tests/vectors/` for golden test vectors including: -- `kernel_input_v1.json` - KernelInputV1 encoding vectors -- `kernel_journal_v1.json` - KernelJournalV1 encoding vectors - -Each vector file contains: -- Positive vectors with fields, encoded hex, and commitment hex -- Negative vectors with invalid encodings and expected errors diff --git a/docs/P0.3_DOCUMENTATION.md b/docs/P0.3_DOCUMENTATION.md deleted file mode 100644 index fdb494b..0000000 --- a/docs/P0.3_DOCUMENTATION.md +++ /dev/null @@ -1,309 +0,0 @@ -# P0.3: Constraint System Documentation - -## Overview - -P0.3 implements an unskippable, auditable constraint engine inside the zkVM guest that validates all agent-proposed actions against economic safety rules. If any rule is violated, execution provably fails and produces a Failure journal with an empty action commitment. - -## Architecture - -### Dataflow - -1. Host provides `KernelInputV1` containing constraint_set_hash, opaque_agent_inputs, etc. -2. Guest runs agent code → returns proposed `AgentOutput` -3. Guest runs constraint engine over proposed output: - - Validates constraint set invariants - - Validates every action + global invariants -4. If valid: - - Compute `action_commitment = SHA256(encoded AgentOutput)` - - Produce `KernelJournalV1 { execution_status = Success, ... }` -5. If invalid: - - Set `execution_status = Failure` - - Set `action_commitment` to empty output commitment - - Produce valid journal (proof still verifies, but verifier rejects state transition) - -### Module Structure - -``` -crates/ -├── constraints/ # Constraint engine implementation -│ └── src/lib.rs # enforce_constraints(), payload decoders, validation -├── kernel-guest/ # Kernel integration -│ └── src/lib.rs # kernel_main() calls constraints unconditionally -└── host-tests/ # Comprehensive test suite - └── src/lib.rs # 78+ tests including constraint validation -``` - ---- - -## Constraint Set (ConstraintSetV1) - -The constraint set defines economic safety parameters. For P0.3, constraints are embedded in the guest binary and referenced by `constraint_set_hash`. - -### Schema (60 bytes) - -| Offset | Field | Type | Description | -|--------|-------|------|-------------| -| 0 | version | u32 | Must be 1 | -| 4 | max_position_notional | u64 | Maximum position size | -| 12 | max_leverage_bps | u32 | Maximum leverage (basis points) | -| 16 | max_drawdown_bps | u32 | Maximum drawdown (basis points, ≤10000) | -| 20 | cooldown_seconds | u32 | Minimum seconds between executions | -| 24 | max_actions_per_output | u32 | Maximum actions per output (≤64) | -| 28 | allowed_asset_id | [u8; 32] | Single allowed asset (zero = all allowed) | - -### Invariant Validation (P0.3) - -The constraint set is validated before use: -- `version` must be 1 -- `max_actions_per_output` must be ≤ 64 (protocol maximum); may be 0 (rejects any non-empty output) -- `max_drawdown_bps` must be ≤ 10,000 (100%) -- `max_leverage_bps` may be 0 (only `leverage_bps == 0` passes) - -Invalid constraint sets return `InvalidConstraintSet` error. - -### Default Values - -```rust -ConstraintSetV1 { - version: 1, - max_position_notional: u64::MAX, // No limit - max_leverage_bps: 100_000, // 10x max - max_drawdown_bps: 10_000, // 100% (disabled) - cooldown_seconds: 0, // No cooldown - max_actions_per_output: 64, // Protocol max - allowed_asset_id: [0u8; 32], // All assets allowed -} -``` - ---- - -## Action Types - -### Supported Types (P0.3) - -| Code | Name | Payload Size | -|------|------|--------------| -| 0x00000001 | Echo | Any (no schema) | -| 0x00000002 | OpenPosition | 45 bytes (exact) | -| 0x00000003 | ClosePosition | 32 bytes (exact) | -| 0x00000004 | AdjustPosition | 44 bytes (exact) | -| 0x00000005 | Swap | 72 bytes (exact) | - -Unknown action types return `UnknownActionType` error. - -### Payload Schemas - -**Encoding:** All integer fields in action payloads use little-endian encoding. - -**P0.3 Strict Length Enforcement:** All payloads must be exactly the specified size. Trailing bytes are rejected to prevent encoding malleability. - -#### OpenPosition (45 bytes) -| Offset | Field | Type | Size | -|--------|-------|------|------| -| 0 | asset_id | [u8; 32] | 32 | -| 32 | notional | u64 | 8 | -| 40 | leverage_bps | u32 | 4 | -| 44 | direction | u8 | 1 | - -#### ClosePosition (32 bytes) -| Offset | Field | Type | Size | -|--------|-------|------|------| -| 0 | position_id | [u8; 32] | 32 | - -#### AdjustPosition (44 bytes) -| Offset | Field | Type | Size | -|--------|-------|------|------| -| 0 | position_id | [u8; 32] | 32 | -| 32 | new_notional | u64 | 8 | -| 40 | new_leverage_bps | u32 | 4 | - -#### Swap (72 bytes) -| Offset | Field | Type | Size | -|--------|-------|------|------| -| 0 | from_asset | [u8; 32] | 32 | -| 32 | to_asset | [u8; 32] | 32 | -| 64 | amount | u64 | 8 | - ---- - -## State Snapshot (StateSnapshotV1) - -Required for cooldown and drawdown constraints. Provided in `opaque_agent_inputs`. - -### Schema (36 bytes) - -| Offset | Field | Type | Size | -|--------|-------|------|------| -| 0 | snapshot_version | u32 | 4 | -| 4 | last_execution_ts | u64 | 8 | -| 12 | current_ts | u64 | 8 | -| 20 | current_equity | u64 | 8 | -| 28 | peak_equity | u64 | 8 | - -### Optionality - -**Missing Snapshot Definition:** A snapshot is considered missing if `opaque_agent_inputs.len() < 36` or if `snapshot_version != 1`. - -**Snapshot Present:** A snapshot is present when the snapshot prefix decodes successfully (`snapshot_version == 1` and `len >= 36`). - -Snapshot is optional unless cooldown or drawdown constraints are enabled: -``` -IF snapshot is missing AND (cooldown_seconds > 0 OR max_drawdown_bps < 10_000): - Violation: InvalidStateSnapshot -ELSE IF snapshot is missing: - snapshot is considered empty; global checks are skipped -``` - ---- - -## Constraint Rules - -### Evaluation Order (Deterministic) - -1. Validate constraint set version and invariants -2. Validate output structure (action count, payload sizes) -3. For each action (in order): - - Validate action type is known - - Decode and validate payload schema - - Check asset whitelist (if applicable) - - Check position size (if applicable; for AdjustPosition, only when `new_notional > 0`) - - Check leverage (if applicable; for AdjustPosition, only when `new_leverage_bps > 0`) -4. Validate global invariants (cooldown, drawdown) -5. First violation stops evaluation - -### Asset Whitelist - -``` -IF allowed_asset_id != [0; 32]: - REQUIRE: asset_id == allowed_asset_id (exact match) -``` - -P0.3 supports single-asset whitelist only. Zero means all assets allowed. - -### Cooldown - -``` -IF cooldown_seconds > 0: - required_ts = last_execution_ts + cooldown_seconds - IF overflow: InvalidStateSnapshot - REQUIRE: current_ts >= required_ts -``` - -**Overflow Protection:** `checked_add` is used to prevent malicious timestamps from bypassing cooldown via saturation. - -**Timestamp Arithmetic:** All timestamp arithmetic is performed in `u64`; overflow is treated as `InvalidStateSnapshot`. - -### Drawdown - -``` -IF max_drawdown_bps < 10000: - IF peak_equity == 0: InvalidStateSnapshot - drawdown = if current_equity >= peak_equity { 0 } else { peak_equity - current_equity } - drawdown_bps = drawdown * 10000 / peak_equity - REQUIRE: drawdown_bps <= max_drawdown_bps -``` - -**Drawdown Disabled Rule:** Drawdown checks are disabled if and only if `max_drawdown_bps == 10_000` (100%). - -When `current_equity >= peak_equity`, drawdown is defined as 0 (no drawdown). This prevents underflow and makes the rule deterministic. - ---- - -## Failure Semantics - -### On Constraint Violation - -1. `execution_status` = Failure (0x02) -2. `action_commitment` = SHA256([0x00, 0x00, 0x00, 0x00]) (empty output) -3. Valid `KernelJournalV1` is always produced -4. Proof still verifies, but verifiers/contracts should reject state transitions - -### Empty Output Commitment - -``` -df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119 -``` - -This is the SHA-256 hash of an empty `AgentOutput` (action_count = 0). - -### Violation Reason Codes - -| Code | Name | Description | -|------|------|-------------| -| 0x01 | InvalidOutputStructure | Too many actions or payload too large | -| 0x02 | UnknownActionType | Action type not recognized | -| 0x03 | AssetNotWhitelisted | Asset not in allowed list | -| 0x04 | PositionTooLarge | Position exceeds size limit | -| 0x05 | LeverageTooHigh | Leverage exceeds limit | -| 0x06 | DrawdownExceeded | Portfolio drawdown too high | -| 0x07 | CooldownNotElapsed | Too soon since last execution | -| 0x08 | InvalidStateSnapshot | Snapshot malformed or invalid | -| 0x09 | InvalidConstraintSet | Constraint configuration invalid | -| 0x0A | InvalidActionPayload | Payload doesn't match schema | - ---- - -## Target Field Limitation - -The `action.target` field is **not validated** by the constraint engine in P0.3. This field is passed through to executor contracts without constraint enforcement. - -Executor contracts are responsible for validating the target field according to their own rules. - -**Security Posture:** If the executor allows arbitrary calls based on `target`, then P0.3 constraints do not prevent malicious call targets. Operators must ensure executors implement appropriate target validation. - ---- - -## API Reference - -### Primary Function - -```rust -pub fn enforce_constraints( - input: &KernelInputV1, - proposed: &AgentOutput, - constraint_set: &ConstraintSetV1, -) -> Result -``` - -Returns the validated output (same as proposed) if all constraints pass, or a `ConstraintViolation` with reason code and optional action index. - -### Integration in kernel_main - -```rust -// 1. Execute agent -let proposed_output = agent.run(&input); - -// 2. Enforce constraints (MANDATORY) -let (validated_output, status) = match enforce_constraints(&input, &proposed_output, &constraints) { - Ok(output) => (output, ExecutionStatus::Success), - Err(_) => (AgentOutput { actions: vec![] }, ExecutionStatus::Failure), -}; - -// 3. Compute commitment over validated output -let action_commitment = compute_action_commitment(&validated_output.encode()?); - -// 4. Produce journal (always) -``` - ---- - -## Test Coverage - -82 tests covering: -- Payload trailing bytes rejection (all 4 types) -- Constraint set invariant validation -- Cooldown timestamp overflow protection -- Asset whitelist rejection (OpenPosition, Swap from/to) -- All violation reason codes -- Golden vectors for commitments -- Round-trip encoding/decoding - -See `crates/host-tests/src/lib.rs` and `tests/vectors/constraints/constraint_vectors.json`. - ---- - -## References - -- Full specification: `spec/constraints.md` -- Test vectors: `tests/vectors/constraints/` -- Implementation: `crates/constraints/src/lib.rs` diff --git a/docs/P0.4_DOCUMENTATION.md b/docs/P0.4_DOCUMENTATION.md deleted file mode 100644 index 62b6496..0000000 --- a/docs/P0.4_DOCUMENTATION.md +++ /dev/null @@ -1,1117 +0,0 @@ -# Kernel SDK Documentation (P0.4) - -This document provides comprehensive usage documentation for the `kernel-sdk` crate. For the formal specification, see `spec/sdk.md`. - ---- - -## Table of Contents - -1. [Quick Start](#1-quick-start) -2. [Project Setup](#2-project-setup) -3. [Agent Lifecycle](#3-agent-lifecycle) -4. [The AgentContext](#4-the-agentcontext) -5. [Producing Actions](#5-producing-actions) -6. [Parsing Inputs](#6-parsing-inputs) -7. [Math Operations](#7-math-operations) -8. [Common Patterns](#8-common-patterns) -9. [Error Handling](#9-error-handling) -10. [Testing Agents](#10-testing-agents) -11. [Building for zkVM](#11-building-for-zkvm) -12. [Constraints Integration](#12-constraints-integration) -13. [Reference Implementations](#13-reference-implementations) -14. [Troubleshooting](#14-troubleshooting) - ---- - -## 1. Quick Start - -### Minimal Agent - -```rust -#![no_std] -#![no_main] - -extern crate alloc; - -use kernel_sdk::prelude::*; - -#[no_mangle] -#[allow(improper_ctypes_definitions)] -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - // Echo the inputs back - let action = echo_action(*ctx.agent_id, ctx.opaque_inputs.to_vec()); - - let mut actions = Vec::with_capacity(1); - actions.push(action); - AgentOutput { actions } -} -``` - -### Key Requirements - -1. **Function name must be `agent_main`** - no exceptions -2. **Must use `#[no_mangle]` and `extern "C"`** - for kernel linkage -3. **Must return `AgentOutput`** - even if empty -4. **Never panic** - panics invalidate the proof - ---- - -## 2. Project Setup - -### Cargo.toml - -```toml -[package] -name = "my-agent" -version = "0.1.0" -edition = "2021" - -[dependencies] -kernel-sdk = { path = "../kernel-sdk", default-features = false } - -[features] -default = [] -guest = ["kernel-sdk/guest"] - -[profile.release] -opt-level = 3 -lto = true -``` - -### Crate Attributes - -```rust -// Required for zkVM guest -#![no_std] -#![no_main] - -// Required for heap allocation -extern crate alloc; -``` - -### Feature Flags - -| Feature | Purpose | -|---------|---------| -| `default` | No features enabled (recommended) | -| `guest` | Enable zkVM-specific code paths | - ---- - -## 3. Agent Lifecycle - -### Execution Flow - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ KERNEL │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. Decode KernelInputV1 from raw bytes │ -│ 2. Construct AgentContext with validated data │ -│ 3. Call agent_main(ctx) ─────────┐ │ -│ 4. Receive AgentOutput ◄────────┘ │ -│ 5. Enforce constraints on proposed actions │ -│ 6. Commit validated output (or failure journal) │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ YOUR AGENT │ -├─────────────────────────────────────────────────────────────────┤ -│ fn agent_main(ctx: &AgentContext) -> AgentOutput { │ -│ // 1. Read context fields │ -│ // 2. Parse opaque_inputs │ -│ // 3. Apply trading logic │ -│ // 4. Build actions │ -│ // 5. Return AgentOutput │ -│ } │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### What Agents CAN Do - -- Read all fields from `AgentContext` -- Parse `opaque_inputs` using SDK byte helpers -- Perform deterministic calculations -- Produce up to 64 actions -- Return empty output (valid no-op) - -### What Agents CANNOT Do - -- Access system time or randomness -- Perform I/O (filesystem, network) -- Call host functions or syscalls -- Modify kernel state -- Bypass constraint enforcement - ---- - -## 4. The AgentContext - -### Structure Overview - -```rust -pub struct AgentContext<'a> { - pub protocol_version: u32, // Wire format (must be 1) - pub kernel_version: u32, // Kernel semantics (must be 1) - pub agent_id: &'a [u8; 32], // Your unique identifier - pub agent_code_hash: &'a [u8; 32], - pub constraint_set_hash: &'a [u8; 32], - pub input_root: &'a [u8; 32], // External state snapshot - pub execution_nonce: u64, // Replay protection - pub opaque_inputs: &'a [u8], // Your input data -} -``` - -### Version Checking - -Always check versions for forward compatibility: - -```rust -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - // Defensive: reject unsupported kernel versions - if !ctx.is_kernel_v1() { - return AgentOutput { actions: Vec::new() }; - } - - // Your logic here... -} -``` - -### Reading Agent ID - -The `agent_id` is commonly used as the default target: - -```rust -// Use agent_id as target (dereference to get owned [u8; 32]) -let target = *ctx.agent_id; - -let action = echo_action(target, payload); -``` - -### Reading Opaque Inputs - -The `opaque_inputs` field contains your custom data: - -```rust -// Check if inputs exist -if ctx.inputs_is_empty() { - return AgentOutput { actions: Vec::new() }; -} - -// Get input length -let len = ctx.inputs_len(); - -// Read raw bytes -let raw = ctx.opaque_inputs; -``` - -### Snapshot Prefix Convention - -When constraints are enabled, the first 36 bytes contain a `StateSnapshotV1`: - -``` -┌────────────────────────────────────────────────────────────────┐ -│ opaque_inputs │ -├──────────────────────────────────┬─────────────────────────────┤ -│ StateSnapshotV1 (36 bytes) │ Agent-Specific Data │ -├──────────────────────────────────┼─────────────────────────────┤ -│ Offset 0-3: snapshot_version │ Your custom input data │ -│ Offset 4-11: last_execution_ts │ (model params, signals, │ -│ Offset 12-19: current_ts │ trade instructions, etc.) │ -│ Offset 20-27: current_equity │ │ -│ Offset 28-35: peak_equity │ │ -└──────────────────────────────────┴─────────────────────────────┘ -``` - -Use the helper to skip the prefix: - -```rust -// Get bytes after the 36-byte snapshot prefix -let agent_data = ctx.agent_inputs(); - -// Check if snapshot is present -if ctx.has_snapshot_prefix() { - // Safe to parse snapshot fields from ctx.opaque_inputs[0..36] -} -``` - ---- - -## 5. Producing Actions - -### Action Structure - -```rust -pub struct ActionV1 { - pub action_type: u32, // Action kind identifier - pub target: [u8; 32], // Target address/identifier - pub payload: Vec, // Action-specific data -} -``` - -### Action Types - -| Constant | Value | Payload Size | Description | -|----------|-------|--------------|-------------| -| `ACTION_TYPE_ECHO` | 1 | Variable | Test/debug action | -| `ACTION_TYPE_OPEN_POSITION` | 2 | 45 bytes | Open trading position | -| `ACTION_TYPE_CLOSE_POSITION` | 3 | 32 bytes | Close position | -| `ACTION_TYPE_ADJUST_POSITION` | 4 | 44 bytes | Modify position | -| `ACTION_TYPE_SWAP` | 5 | 72 bytes | Asset swap | - -### Using Action Constructors - -**Echo Action** - For testing: - -```rust -let action = echo_action( - *ctx.agent_id, // target - ctx.opaque_inputs.to_vec(), // payload (any bytes) -); -``` - -**Open Position** - Start a new trade: - -```rust -let asset_id = [0x42u8; 32]; // Asset to trade - -let action = open_position_action( - *ctx.agent_id, // target - asset_id, // asset identifier - 1_000_000, // notional (position size) - 20_000, // leverage_bps (2x = 20000) - 0, // direction: 0 = Long, 1 = Short -); -``` - -**Close Position** - Exit a trade: - -```rust -let position_id = [0x99u8; 32]; // Position to close - -let action = close_position_action( - *ctx.agent_id, // target - position_id, // position identifier -); -``` - -**Adjust Position** - Modify an existing trade: - -```rust -let position_id = [0x99u8; 32]; - -let action = adjust_position_action( - *ctx.agent_id, // target - position_id, // position to modify - 2_000_000, // new_notional (0 = unchanged) - 15_000, // new_leverage_bps (0 = unchanged) -); -``` - -**Swap** - Exchange assets: - -```rust -let from_asset = [0x11u8; 32]; -let to_asset = [0x22u8; 32]; - -let action = swap_action( - *ctx.agent_id, // target - from_asset, // source asset - to_asset, // destination asset - 500_000, // amount to swap -); -``` - -### Building AgentOutput - -Always use bounded allocation: - -```rust -// Single action -let mut actions = Vec::with_capacity(1); -actions.push(action); -AgentOutput { actions } - -// Multiple actions -let mut actions = Vec::with_capacity(3); -actions.push(action1); -actions.push(action2); -actions.push(action3); -AgentOutput { actions } - -// No actions (valid no-op) -AgentOutput { actions: Vec::new() } -``` - -### Action Limits - -| Limit | Value | -|-------|-------| -| Max actions per output | 64 | -| Max payload per action | 16,384 bytes | - ---- - -## 6. Parsing Inputs - -### Fixed-Offset Reading - -Use when you know exact positions: - -```rust -let inputs = ctx.agent_inputs(); - -// Read at specific offsets -let asset_id = match read_bytes32(inputs, 0) { - Some(id) => id, - None => return AgentOutput { actions: Vec::new() }, -}; - -let amount = match read_u64_le(inputs, 32) { - Some(a) => a, - None => return AgentOutput { actions: Vec::new() }, -}; - -let direction = match read_u8(inputs, 40) { - Some(d) => d, - None => return AgentOutput { actions: Vec::new() }, -}; -``` - -### Cursor-Style Reading - -Use for sequential parsing with automatic offset tracking: - -```rust -let inputs = ctx.agent_inputs(); -let mut offset = 0; - -// Each read advances offset automatically -let asset_id = match read_bytes32_at(inputs, &mut offset) { - Some(id) => id, - None => return AgentOutput { actions: Vec::new() }, -}; - -let amount = match read_u64_le_at(inputs, &mut offset) { - Some(a) => a, - None => return AgentOutput { actions: Vec::new() }, -}; - -let is_long = match read_bool_u8_at(inputs, &mut offset) { - Some(b) => b, - None => return AgentOutput { actions: Vec::new() }, -}; -// offset is now 41 (32 + 8 + 1) -``` - -### Available Readers - -**Fixed-offset** (offset not modified): - -```rust -read_u8(bytes, offset) -> Option -read_u32_le(bytes, offset) -> Option -read_u64_le(bytes, offset) -> Option -read_bytes32(bytes, offset) -> Option<[u8; 32]> -read_slice(bytes, offset, len) -> Option<&[u8]> -read_bool_u8(bytes, offset) -> Option -``` - -**Cursor-style** (offset advanced on success): - -```rust -read_u8_at(bytes, &mut offset) -> Option -read_u32_le_at(bytes, &mut offset) -> Option -read_u64_le_at(bytes, &mut offset) -> Option -read_bytes32_at(bytes, &mut offset) -> Option<[u8; 32]> -read_slice_at(bytes, &mut offset, len) -> Option<&[u8]> -read_bool_u8_at(bytes, &mut offset) -> Option // No advance on invalid! -``` - -### Boolean Parsing - -The `read_bool_u8` functions use strict interpretation: - -| Byte Value | Result | -|------------|--------| -| `0x00` | `Some(false)` | -| `0x01` | `Some(true)` | -| Any other | `None` | - -For `read_bool_u8_at`, the offset is **not advanced** when returning `None` (fail-without-consuming semantics). - ---- - -## 7. Math Operations - -### Checked Arithmetic - -Returns `None` on overflow/underflow/divide-by-zero: - -```rust -// Addition -let sum = checked_add_u64(a, b)?; - -// Subtraction -let diff = checked_sub_u64(a, b)?; - -// Multiplication -let product = checked_mul_u64(a, b)?; - -// Division (floor/truncation) -let quotient = checked_div_u64(a, b)?; -``` - -### Compound Operations - -For ratio calculations, use `checked_mul_div_u64`: - -```rust -// Calculate (a * b) / c safely -// This is the canonical primitive for proportional calculations -let result = checked_mul_div_u64(amount, rate, denominator)?; - -// Example: 70% of 1000 -let seventy_percent = checked_mul_div_u64(1000, 70, 100)?; // = 700 -``` - -### Saturating Arithmetic - -Returns boundary values instead of overflowing: - -```rust -// Returns u64::MAX on overflow -let sum = saturating_add_u64(a, b); - -// Returns 0 on underflow -let diff = saturating_sub_u64(a, b); - -// Returns u64::MAX on overflow -let product = saturating_mul_u64(a, b); -``` - -### Basis Points - -Basis points (bps) are used for percentages and leverage: -- 1 bps = 0.01% -- 100 bps = 1% -- 10,000 bps = 100% - -```rust -const BPS_DENOMINATOR: u64 = 10_000; - -// Apply percentage: value * bps / 10000 -let fee = apply_bps(amount, 50)?; // 0.5% fee - -// Calculate percentage: num * 10000 / denom -let rate_bps = calculate_bps(profit, principal)?; - -// Calculate drawdown -let drawdown = drawdown_bps(current_equity, peak_equity)?; -``` - -### Min/Max/Clamp - -```rust -let smaller = min_u64(a, b); -let larger = max_u64(a, b); -let bounded = clamp_u64(value, min, max); -``` - ---- - -## 8. Common Patterns - -### Pattern: Graceful Degradation - -Always return valid output, never panic: - -```rust -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - // Check preconditions - if !ctx.is_kernel_v1() { - return AgentOutput { actions: Vec::new() }; - } - - // Parse inputs with fallback - let params = match parse_params(ctx.agent_inputs()) { - Some(p) => p, - None => return AgentOutput { actions: Vec::new() }, - }; - - // Compute with checked arithmetic - let size = match checked_mul_u64(params.base, params.multiplier) { - Some(s) => s, - None => return AgentOutput { actions: Vec::new() }, - }; - - // Build output... -} -``` - -### Pattern: Multi-Action Agent - -Produce multiple actions in a single execution: - -```rust -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - let inputs = ctx.agent_inputs(); - let mut offset = 0; - - // Read action count (bounded to prevent DoS) - let count = match read_u8_at(inputs, &mut offset) { - Some(c) if (c as usize) <= MAX_ACTIONS_PER_OUTPUT => c as usize, - _ => return AgentOutput { actions: Vec::new() }, - }; - - let mut actions = Vec::with_capacity(count); - - for _ in 0..count { - let asset_id = match read_bytes32_at(inputs, &mut offset) { - Some(id) => id, - None => break, // Graceful early exit - }; - - let amount = match read_u64_le_at(inputs, &mut offset) { - Some(a) => a, - None => break, - }; - - actions.push(open_position_action( - *ctx.agent_id, - asset_id, - amount, - 10_000, // 1x leverage - 0, // Long - )); - } - - AgentOutput { actions } -} -``` - -### Pattern: Conditional Execution - -Only act when conditions are met: - -```rust -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - let inputs = ctx.agent_inputs(); - let mut offset = 0; - - // Read signal - let signal = match read_u8_at(inputs, &mut offset) { - Some(s) => s, - None => return AgentOutput { actions: Vec::new() }, - }; - - // Only act on specific signals - match signal { - 0x01 => { - // Open long position - let action = open_position_action(/* ... */); - let mut actions = Vec::with_capacity(1); - actions.push(action); - AgentOutput { actions } - } - 0x02 => { - // Open short position - let action = open_position_action(/* ... */); - let mut actions = Vec::with_capacity(1); - actions.push(action); - AgentOutput { actions } - } - _ => { - // No action (hold) - AgentOutput { actions: Vec::new() } - } - } -} -``` - -### Pattern: Input Validation - -Validate inputs before processing: - -```rust -fn validate_trade_params( - notional: u64, - leverage_bps: u32, - direction: u8, -) -> bool { - // Notional must be non-zero - if notional == 0 { - return false; - } - - // Leverage must be reasonable (1x to 10x) - if leverage_bps < 10_000 || leverage_bps > 100_000 { - return false; - } - - // Direction must be 0 or 1 - if direction > 1 { - return false; - } - - true -} -``` - ---- - -## 9. Error Handling - -### Never Panic - -Panicking aborts execution and invalidates the proof: - -```rust -// BAD: This will panic on invalid input -let value = inputs[100]; // Index out of bounds! - -// GOOD: Safe bounds checking -let value = match read_u8(inputs, 100) { - Some(v) => v, - None => return AgentOutput { actions: Vec::new() }, -}; -``` - -### Use Option for Fallible Operations - -```rust -// Parsing might fail -fn parse_trade_signal(inputs: &[u8]) -> Option { - let mut offset = 0; - - let asset_id = read_bytes32_at(inputs, &mut offset)?; - let amount = read_u64_le_at(inputs, &mut offset)?; - let direction = read_u8_at(inputs, &mut offset)?; - - if direction > 1 { - return None; - } - - Some(TradeSignal { asset_id, amount, direction }) -} -``` - -### Propagate Errors with `?` - -```rust -fn compute_position_size( - equity: u64, - risk_bps: u64, - price: u64, -) -> Option { - let risk_amount = apply_bps(equity, risk_bps)?; - let position_size = checked_div_u64(risk_amount, price)?; - Some(position_size) -} -``` - ---- - -## 10. Testing Agents - -### Unit Test Setup - -```rust -#[cfg(test)] -mod tests { - use super::*; - use kernel_sdk::prelude::*; - - fn make_test_context<'a>( - agent_id: &'a [u8; 32], - inputs: &'a [u8], - ) -> AgentContext<'a> { - let code_hash = [0u8; 32]; - let constraint_hash = [0u8; 32]; - let input_root = [0u8; 32]; - - AgentContext::new( - 1, // protocol_version - 1, // kernel_version - agent_id, - &code_hash, - &constraint_hash, - &input_root, - 1, // execution_nonce - inputs, - ) - } - - #[test] - fn test_agent_produces_action() { - let agent_id = [0x42u8; 32]; - let inputs = [1, 2, 3, 4, 5]; - - let ctx = make_test_context(&agent_id, &inputs); - let output = agent_main(&ctx); - - assert_eq!(output.actions.len(), 1); - assert_eq!(output.actions[0].action_type, ACTION_TYPE_ECHO); - } - - #[test] - fn test_agent_handles_empty_input() { - let agent_id = [0x42u8; 32]; - let inputs: [u8; 0] = []; - - let ctx = make_test_context(&agent_id, &inputs); - let output = agent_main(&ctx); - - // Should return empty output, not panic - assert!(output.actions.is_empty() || output.actions.len() == 1); - } -} -``` - -### Testing Payload Encoding - -```rust -#[test] -fn test_open_position_payload() { - let asset_id = [0x11u8; 32]; - let action = open_position_action( - [0u8; 32], // target - asset_id, - 1_000_000, - 20_000, - 0, - ); - - assert_eq!(action.action_type, ACTION_TYPE_OPEN_POSITION); - assert_eq!(action.payload.len(), 45); - - // Verify payload structure - let decoded = decode_open_position_payload(&action.payload).unwrap(); - assert_eq!(decoded.asset_id, asset_id); - assert_eq!(decoded.notional, 1_000_000); - assert_eq!(decoded.leverage_bps, 20_000); - assert_eq!(decoded.direction, 0); -} -``` - -### Running Tests - -```bash -# Run all SDK tests -cargo test -p kernel-sdk - -# Run specific test -cargo test -p kernel-sdk test_agent_produces_action - -# Run with output -cargo test -p kernel-sdk -- --nocapture -``` - ---- - -## 11. Building for zkVM - -### Build Configuration - -```toml -# Cargo.toml for zkVM guest -[package] -name = "my-agent-guest" -edition = "2021" - -[dependencies] -kernel-sdk = { path = "../kernel-sdk", default-features = false, features = ["guest"] } - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -``` - -### Verification Commands - -```bash -# Add wasm target for no_std verification -rustup target add wasm32-unknown-unknown - -# Build without std -cargo build --release --no-default-features --target wasm32-unknown-unknown - -# Check for std leakage -cargo tree -e features | grep '\bstd\b' -``` - -### Size Optimization - -```toml -[profile.release] -opt-level = "z" # Optimize for size -lto = true # Link-time optimization -codegen-units = 1 # Single codegen unit -panic = "abort" # No unwinding -strip = true # Strip symbols -``` - ---- - -## 12. Constraints Integration - -### Understanding Constraint Enforcement - -Agents propose actions; the kernel enforces constraints: - -``` -Agent Output → Constraint Engine → Validated Output or Failure -``` - -### Constraints That May Reject Actions - -| Constraint | Condition | -|------------|-----------| -| `TooManyActions` | More than 64 actions | -| `UnknownActionType` | Invalid action_type value | -| `InvalidActionPayload` | Wrong payload size | -| `AssetNotWhitelisted` | Asset not in allowed list | -| `PositionTooLarge` | Exceeds max_position_notional | -| `LeverageTooHigh` | Exceeds max_leverage_bps | -| `CooldownNotElapsed` | Too soon after last execution | -| `DrawdownExceeded` | Portfolio drawdown too large | - -### Designing for Constraints - -```rust -// Check constraints will be satisfied BEFORE producing action -fn should_open_position( - notional: u64, - leverage_bps: u32, - max_notional: u64, - max_leverage: u32, -) -> bool { - notional <= max_notional && leverage_bps <= max_leverage -} -``` - -### State Snapshot Usage - -When cooldown or drawdown constraints are enabled, read the snapshot: - -```rust -fn read_snapshot(inputs: &[u8]) -> Option { - if inputs.len() < 36 { - return None; - } - - let mut offset = 0; - let version = read_u32_le_at(inputs, &mut offset)?; - if version != 1 { - return None; - } - - Some(StateSnapshot { - last_execution_ts: read_u64_le_at(inputs, &mut offset)?, - current_ts: read_u64_le_at(inputs, &mut offset)?, - current_equity: read_u64_le_at(inputs, &mut offset)?, - peak_equity: read_u64_le_at(inputs, &mut offset)?, - }) -} -``` - ---- - -## 13. Reference Implementations - -### Echo Agent - -The simplest possible agent - echoes inputs back: - -```rust -#[no_mangle] -#[allow(improper_ctypes_definitions)] -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - if !ctx.is_kernel_v1() { - return AgentOutput { actions: Vec::new() }; - } - - // Truncate to max payload size - let max_payload = MAX_ACTION_PAYLOAD_BYTES; - let payload_len = if ctx.opaque_inputs.len() > max_payload { - max_payload - } else { - ctx.opaque_inputs.len() - }; - - let action = ActionV1 { - action_type: ACTION_TYPE_ECHO, - target: *ctx.agent_id, - payload: ctx.opaque_inputs[..payload_len].to_vec(), - }; - - let mut actions = Vec::with_capacity(1); - actions.push(action); - AgentOutput { actions } -} -``` - -### Trading Agent - -A more complex agent that opens positions based on signals: - -```rust -#[no_mangle] -#[allow(improper_ctypes_definitions)] -pub extern "C" fn agent_main(ctx: &AgentContext) -> AgentOutput { - if !ctx.is_kernel_v1() { - return AgentOutput { actions: Vec::new() }; - } - - // Need at least: asset_id (32) + notional (8) + direction (1) = 41 bytes - let inputs = ctx.agent_inputs(); - if inputs.len() < 41 { - return AgentOutput { actions: Vec::new() }; - } - - // Parse inputs - let mut offset = 0; - - let asset_id = match read_bytes32_at(inputs, &mut offset) { - Some(id) => id, - None => return AgentOutput { actions: Vec::new() }, - }; - - let notional = match read_u64_le_at(inputs, &mut offset) { - Some(n) if n > 0 => n, - _ => return AgentOutput { actions: Vec::new() }, - }; - - let direction = match read_u8_at(inputs, &mut offset) { - Some(d) if d <= 1 => d, - _ => return AgentOutput { actions: Vec::new() }, - }; - - // Create action - let action = open_position_action( - *ctx.agent_id, - asset_id, - notional, - 10_000, // 1x leverage - direction, - ); - - let mut actions = Vec::with_capacity(1); - actions.push(action); - AgentOutput { actions } -} -``` - -### No-Op Agent - -Produces no actions (useful for testing constraint engine): - -```rust -#[no_mangle] -#[allow(improper_ctypes_definitions)] -pub extern "C" fn agent_main(_ctx: &AgentContext) -> AgentOutput { - AgentOutput { actions: Vec::new() } -} -``` - ---- - -## 14. Troubleshooting - -### Common Errors - -**"std not found"** -``` -error[E0463]: can't find crate for `std` -``` -Solution: Ensure `#![no_std]` is at the top of your crate root and all dependencies use `default-features = false`. - -**"unresolved import `alloc`"** -``` -error[E0432]: unresolved import `alloc` -``` -Solution: Add `extern crate alloc;` after `#![no_std]`. - -**"symbol `agent_main` not found"** -- Ensure function is named exactly `agent_main` -- Ensure `#[no_mangle]` attribute is present -- Ensure `pub extern "C"` linkage - -**"improper_ctypes_definitions" warning** -``` -warning: `extern` fn uses type `AgentOutput`, which is not FFI-safe -``` -Solution: Add `#[allow(improper_ctypes_definitions)]` (this is expected for zkVM). - -### Debugging Tips - -1. **Use echo actions for debugging** - ```rust - // Debug: echo the parsed values back - let mut debug_payload = Vec::with_capacity(16); - write_u64_le(&mut debug_payload, computed_value); - let debug_action = echo_action(*ctx.agent_id, debug_payload); - ``` - -2. **Check input lengths** - ```rust - // Log input state via echo - let len_bytes = (ctx.inputs_len() as u64).to_le_bytes(); - let debug = echo_action(*ctx.agent_id, len_bytes.to_vec()); - ``` - -3. **Test locally before zkVM** - ```bash - cargo test -p my-agent - ``` - -### Performance Tips - -1. **Use `Vec::with_capacity`** - Pre-allocate known sizes -2. **Avoid cloning large data** - Use references where possible -3. **Early return on invalid input** - Don't process bad data -4. **Minimize allocations** - Reuse buffers when possible - ---- - -## Appendix: Quick Reference - -### Prelude Imports - -```rust -use kernel_sdk::prelude::*; -// Imports: AgentContext, AgentOutput, ActionV1, Vec, -// all action constructors, all byte helpers, -// all math helpers, all constants -``` - -### Constants - -| Constant | Value | -|----------|-------| -| `MAX_ACTIONS_PER_OUTPUT` | 64 | -| `MAX_ACTION_PAYLOAD_BYTES` | 16,384 | -| `BPS_DENOMINATOR` | 10,000 | -| `SDK_VERSION` | 0x00_01_00 | - -### Action Type Values - -| Action | Value | -|--------|-------| -| Echo | 0x00000001 | -| Open Position | 0x00000002 | -| Close Position | 0x00000003 | -| Adjust Position | 0x00000004 | -| Swap | 0x00000005 | - -### Payload Sizes - -| Action | Size | -|--------|------| -| Echo | Variable | -| Open Position | 45 bytes | -| Close Position | 32 bytes | -| Adjust Position | 44 bytes | -| Swap | 72 bytes | diff --git a/docs/agent-pack.md b/docs/agent-pack.md new file mode 100644 index 0000000..703ce89 --- /dev/null +++ b/docs/agent-pack.md @@ -0,0 +1,247 @@ +# Agent Pack + +Agent Pack is a portable bundle format for distributing verifiable agents. It provides a standardized way for third-party developers to package their agents with all the cryptographic commitments needed for integrators and marketplaces to verify authenticity without touching kernel internals. + +## Introduction + +When you build an agent for the Execution Kernel, the compilation process produces several cryptographic artifacts that bind your code to an on-chain identity. The Agent Pack format captures these artifacts along with metadata about your agent, creating a self-contained bundle that anyone can verify. + +An Agent Pack manifest answers the question: "Is this agent binary authentic, and does it match what was registered on-chain?" Integrators can perform this verification entirely offline, without needing access to the original build environment or trusting the agent developer. + +## The Cryptographic Chain + +The security of Agent Pack rests on a chain of cryptographic commitments that link your source code to the on-chain verifier: + +``` +Agent Source Code + │ + ▼ (build.rs computes SHA-256) +agent_code_hash ─────────────────────────┐ + │ │ + ▼ (RISC Zero compilation) │ + ELF Binary │ + │ │ + ├──▶ elf_sha256 (SHA-256) │ + │ │ + ▼ (RISC Zero hash) │ + image_id ─────────────────────────────┤ + │ │ + ▼ (deployed on-chain) │ + Verifier Contract ◀─────────────────────┘ +``` + +Each step in this chain is deterministic. Given the same source code and build environment, you will always get the same `agent_code_hash`, the same ELF binary, and the same `image_id`. This reproducibility is what makes verification possible. + +The `image_id` is the critical link to on-chain verification. When a proof is submitted to the verifier contract, the contract checks that the proof was generated by a program with a specific `image_id`. By including the `image_id` in the manifest, integrators can verify that a given agent binary corresponds to what's registered on-chain. + +## Manifest Contents + +The manifest is a JSON file containing all metadata and commitments needed for verification. Here's what each field represents: + +### Identity Fields + +- **format_version**: Always "1" for this version of the format +- **agent_name**: Human-readable name (e.g., "yield-agent") +- **agent_version**: Semantic version (e.g., "0.1.0") +- **agent_id**: 32-byte identifier registered in the kernel + +### Protocol Compatibility + +- **protocol_version**: The kernel protocol version this agent targets +- **kernel_version**: The kernel version used during compilation +- **risc0_version**: RISC Zero zkVM version (e.g., "3.0.4") +- **rust_toolchain**: Rust compiler version (e.g., "1.75.0") + +### Cryptographic Commitments + +- **agent_code_hash**: SHA-256 of the agent code, computed at build time by `build.rs` +- **image_id**: RISC Zero IMAGE_ID computed from the ELF binary + +### Artifacts + +- **artifacts.elf_path**: Path to the ELF binary relative to the manifest +- **artifacts.elf_sha256**: SHA-256 of the ELF binary for integrity verification + +### Build Information + +- **build.cargo_lock_sha256**: SHA-256 of Cargo.lock for dependency pinning +- **build.build_command**: Exact command to reproduce the build +- **build.reproducible**: Whether reproducible builds are enabled (using Docker) + +### Documentation + +- **inputs**: Human-readable description of the expected input format +- **actions_profile**: Description of what actions the agent produces + +### Deployment (Optional) + +- **networks**: Map of network names to deployment addresses +- **git**: Repository URL and commit hash for source traceability +- **notes**: Any additional information + +## Producing an Agent Pack + +### Step 1: Initialize the Manifest + +Start by creating a manifest template with your agent's identity: + +```bash +agent-pack init \ + --name my-yield-agent \ + --version 1.0.0 \ + --agent-id 0x0000000000000000000000000000000000000000000000000000000000000042 +``` + +This creates `./dist/agent-pack.json` with placeholder values for computed fields. + +### Step 2: Build Your Agent + +Build your agent using the RISC Zero toolchain. For reproducible builds, use Docker: + +```bash +RISC0_USE_DOCKER=1 cargo build --release -p risc0-methods +``` + +The ELF binary will be in `target/riscv-guest/riscv32im-risc0-zkvm-elf/release/`. + +### Step 3: Compute Hashes + +Use the `compute` command to populate the cryptographic fields: + +```bash +agent-pack compute \ + --elf target/riscv-guest/riscv32im-risc0-zkvm-elf/release/zkvm-guest \ + --out dist/agent-pack.json \ + --cargo-lock Cargo.lock +``` + +This computes and updates: +- `artifacts.elf_sha256` from the ELF binary +- `image_id` from the ELF binary (requires `--features risc0`) +- `build.cargo_lock_sha256` from your lock file + +### Step 4: Fill in Documentation + +Edit the manifest to add: +- A description of your input format in `inputs` +- What actions your agent produces in `actions_profile` +- Network deployment addresses in `networks` +- Git repository information in `git` + +### Step 5: Verify the Manifest + +Run verification to ensure everything is correct: + +```bash +agent-pack verify --manifest dist/agent-pack.json +``` + +## Verifying an Agent Pack + +Integrators receive an Agent Pack (manifest + ELF binary) and want to verify its authenticity. + +### Basic Verification + +Structure-only verification checks that the manifest is well-formed: + +```bash +agent-pack verify --manifest agent-pack.json --structure-only +``` + +This validates: +- All required fields are present +- Hex strings have correct format (0x prefix, 64 hex chars) +- Version strings are valid semver +- No placeholder values remain + +### Full Verification + +Full verification also checks the ELF binary: + +```bash +agent-pack verify --manifest agent-pack.json --base-dir ./artifacts +``` + +This additionally: +- Verifies the ELF file exists +- Recomputes `elf_sha256` and compares to manifest +- Recomputes `image_id` and compares to manifest (if built with `--features risc0`) + +### On-Chain Verification + +After offline verification passes, compare the `image_id` from the manifest against the value registered in the on-chain verifier contract. If they match, the agent binary is authentic. + +## Reproducible Builds + +For maximum trust, use reproducible builds with Docker: + +```bash +RISC0_USE_DOCKER=1 cargo build --release -p risc0-methods +``` + +This ensures that anyone with the same source code and Cargo.lock can produce an identical ELF binary, which means an identical `image_id`. The `build.reproducible` field indicates whether this was used. + +When verifying an agent with `reproducible: true`, integrators can: +1. Check out the source at the specified git commit +2. Run the build command +3. Compare the resulting `elf_sha256` and `image_id` to the manifest + +## CLI Reference + +### `agent-pack init` + +Creates a new manifest template. + +``` +USAGE: + agent-pack init [OPTIONS] --name --version --agent-id + +OPTIONS: + -n, --name Agent name + -v, --version Agent version (semver) + -a, --agent-id 32-byte agent ID (0x hex) + -o, --out Output path [default: ./dist/agent-pack.json] +``` + +### `agent-pack compute` + +Computes hashes from ELF binary and updates the manifest. + +``` +USAGE: + agent-pack compute [OPTIONS] --elf + +OPTIONS: + -e, --elf Path to ELF binary + -o, --out Manifest path [default: ./dist/agent-pack.json] + --cargo-lock Path to Cargo.lock for hash computation +``` + +Note: IMAGE_ID computation requires building with `--features risc0`. + +### `agent-pack verify` + +Verifies a manifest's integrity. + +``` +USAGE: + agent-pack verify [OPTIONS] + +OPTIONS: + -m, --manifest Manifest path [default: ./dist/agent-pack.json] + -b, --base-dir Base directory for resolving artifact paths + --structure-only Only verify manifest structure, skip file verification +``` + +## JSON Schema + +A JSON Schema for validating manifests is available at `docs/agent-pack.schema.json`. Use it with your favorite JSON validator: + +```bash +# Using ajv-cli +npx ajv validate -s docs/agent-pack.schema.json -d dist/agent-pack.json +``` + +## Example Manifest + +See `dist/agent-pack.example.json` for a complete example using the yield agent. diff --git a/docs/agent-pack.schema.json b/docs/agent-pack.schema.json new file mode 100644 index 0000000..3eee547 --- /dev/null +++ b/docs/agent-pack.schema.json @@ -0,0 +1,175 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Defiesta/execution-kernel/agent-pack.schema.json", + "title": "Agent Pack Manifest", + "description": "Schema for Agent Pack manifest files - portable bundles for verifiable agent distribution", + "type": "object", + "required": [ + "format_version", + "agent_name", + "agent_version", + "agent_id", + "protocol_version", + "kernel_version", + "risc0_version", + "rust_toolchain", + "agent_code_hash", + "image_id", + "artifacts", + "build", + "inputs", + "actions_profile" + ], + "properties": { + "format_version": { + "type": "string", + "const": "1", + "description": "Format version of the Agent Pack manifest" + }, + "agent_name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9-]+$", + "description": "Human-readable agent name (lowercase, alphanumeric, hyphens)" + }, + "agent_version": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "description": "Semantic version of the agent (semver format)" + }, + "agent_id": { + "$ref": "#/$defs/hex32", + "description": "32-byte agent identifier registered on-chain" + }, + "protocol_version": { + "type": "integer", + "minimum": 1, + "description": "Protocol version this agent targets" + }, + "kernel_version": { + "type": "integer", + "minimum": 1, + "description": "Kernel version this agent was built for" + }, + "risc0_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", + "description": "RISC Zero zkVM version used for compilation" + }, + "rust_toolchain": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Rust toolchain version used for compilation" + }, + "agent_code_hash": { + "$ref": "#/$defs/hex32", + "description": "SHA-256 hash of the agent code computed by build.rs" + }, + "image_id": { + "$ref": "#/$defs/hex32", + "description": "RISC Zero IMAGE_ID computed from the ELF binary" + }, + "artifacts": { + "type": "object", + "required": ["elf_path", "elf_sha256"], + "properties": { + "elf_path": { + "type": "string", + "minLength": 1, + "description": "Relative path to the ELF binary" + }, + "elf_sha256": { + "$ref": "#/$defs/hex32", + "description": "SHA-256 hash of the ELF binary" + } + }, + "additionalProperties": false, + "description": "Build artifact information" + }, + "build": { + "type": "object", + "required": ["cargo_lock_sha256", "build_command", "reproducible"], + "properties": { + "cargo_lock_sha256": { + "$ref": "#/$defs/hex32", + "description": "SHA-256 hash of Cargo.lock for dependency verification" + }, + "build_command": { + "type": "string", + "minLength": 1, + "description": "Command used to build the agent" + }, + "reproducible": { + "type": "boolean", + "description": "Whether the build is reproducible (e.g., using Docker)" + } + }, + "additionalProperties": false, + "description": "Build configuration for reproducibility" + }, + "inputs": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of expected input format" + }, + "actions_profile": { + "type": "string", + "minLength": 1, + "description": "Human-readable description of actions produced by the agent" + }, + "networks": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["verifier"], + "properties": { + "verifier": { + "$ref": "#/$defs/ethAddress", + "description": "Address of the RISC Zero verifier contract" + }, + "vault": { + "$ref": "#/$defs/ethAddress", + "description": "Address of the vault or target contract" + } + }, + "additionalProperties": false + }, + "description": "Network-specific deployment addresses" + }, + "git": { + "type": "object", + "required": ["repo", "commit"], + "properties": { + "repo": { + "type": "string", + "format": "uri", + "description": "Git repository URL" + }, + "commit": { + "type": "string", + "pattern": "^[a-f0-9]{7,40}$", + "description": "Git commit hash (short or full)" + } + }, + "additionalProperties": false, + "description": "Git repository information for source traceability" + }, + "notes": { + "type": "string", + "description": "Additional notes or comments about the agent" + } + }, + "additionalProperties": false, + "$defs": { + "hex32": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{64}$", + "description": "32-byte hex string with 0x prefix (64 hex characters)" + }, + "ethAddress": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$", + "description": "Ethereum address with 0x prefix (40 hex characters)" + } + } +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..7dcc321 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,117 @@ +# Agents + +An agent is a program that makes decisions about capital allocation. It receives inputs describing the current state of the world, analyzes them according to its strategy, and produces a set of actions to be executed on-chain. The execution kernel provides the trusted environment where this decision-making happens, and the zkVM provides the cryptographic proof that the decisions were made correctly. + +This document explains what agents are, how they interact with the kernel, and how they become executable zkVM programs. + +## What is an Agent? + +In this system, an agent is not a smart contract, not an off-chain bot, and not a traditional program that runs continuously. An agent is a pure function: given some inputs, it produces some outputs. It has no persistent state, no network access, no ability to read the current time, and no way to interact with the outside world except through the inputs it receives and the outputs it produces. + +This purity is not a limitation—it's the foundation of verifiability. Because an agent is a pure function, we can prove exactly what it did. The inputs are committed to in the proof, the outputs are committed to in the proof, and anyone can verify that the outputs are exactly what the agent's code would produce given those inputs. + +An agent's inputs arrive as opaque bytes in the `opaque_agent_inputs` field of `KernelInputV1`. The kernel doesn't interpret these bytes; it passes them directly to the agent. The agent is responsible for parsing and validating its own input format. This design allows different agents to have completely different input schemas without requiring changes to the kernel. + +An agent's outputs are structured as `AgentOutput`, which contains a vector of `ActionV1` entries. Each action has a type (like CALL or TRANSFER_ERC20), a target address, and a payload. The kernel commits to these outputs and passes them through the constraint engine for validation. + +## The agent_main Contract + +Every agent must implement a function with the following signature: + +```rust +pub fn agent_main(ctx: &AgentContext, opaque_inputs: &[u8]) -> AgentOutput +``` + +This function is the agent's entire interface with the kernel. The kernel calls it exactly once per execution, passing the context and the raw input bytes, and expects an `AgentOutput` in return. + +The `AgentContext` provides information the agent might need about its execution environment: the agent's own identifier, the constraint set hash, and other metadata. Most agents use this sparingly—the primary input is the opaque bytes. + +The `AgentOutput` contains the actions the agent wants to execute. An agent can produce zero actions (if it decides to do nothing), one action (the common case), or multiple actions (for complex strategies). The kernel will validate all actions against the constraint engine before committing them. + +The agent_main function must be deterministic. Given the same context and inputs, it must produce the same output every time. This is enforced by the zkVM environment, which will fail to generate a proof if execution diverges. + +The agent_main function should not panic except in truly exceptional circumstances. If an agent encounters invalid input, it should return an empty `AgentOutput` rather than panicking. Panics abort proof generation entirely, which may not be the desired behavior. + +## Producing Actions + +The `AgentOutput` type is simple: it contains a vector of actions. Each action represents an instruction that will be executed on-chain if the proof verifies successfully. + +Actions have three fields: `action_type` identifies what kind of action this is, `target` specifies the address or identifier the action applies to, and `payload` contains action-specific data. + +The kernel-sdk provides helper functions for constructing common action types: + +```rust +// Create a CALL action (invoke a contract with value and calldata) +let action = call_action(target, value, &calldata); + +// Create a TRANSFER_ERC20 action +let action = transfer_erc20_action(token, recipient, amount); +``` + +These helpers handle the encoding details, ensuring the payload is correctly formatted for the constraint engine and on-chain executor. + +The order of actions in `AgentOutput` is preserved through encoding and execution. If an agent produces actions A, B, C in that order, they will be validated and executed in that order. Agents that need specific ordering (like approve-then-transfer patterns) can rely on this property. + +## Why Agents Don't Touch the Kernel + +A natural question is why agents don't simply import kernel-guest and call functions directly. The answer involves both practical and security considerations. + +From a practical standpoint, direct coupling would mean that every agent change requires recompiling the kernel. Agent developers would need to fork the kernel repository, modify it, and maintain their fork. This creates friction and makes it harder to adopt kernel upgrades. + +From a security standpoint, the kernel is consensus-critical code that must be carefully audited. If agents could arbitrarily call kernel internals, the audit surface would expand to include every agent. By forcing agents to communicate through the narrow `agent_main` interface, we can audit the kernel independently and trust that its invariants hold regardless of what agents do. + +The separation also enables different trust models. A vault might trust a specific kernel version (identified by imageId) while being skeptical of individual agents. The architecture supports this: the kernel's behavior is fixed by its imageId, while agents are identified by their agent_code_hash within that kernel. + +## The Role of Wrapper Crates + +Agents don't implement `AgentEntrypoint` directly. Instead, a wrapper crate provides this implementation, connecting the kernel's generic interface to the agent's specific `agent_main` function. + +A wrapper crate is typically very small. It imports the agent crate, implements `AgentEntrypoint` by delegating to `agent_main`, and provides a convenience function for calling `kernel_main_with_agent`. The entire implementation might be twenty lines of code. + +Why have this extra layer? Several reasons: + +First, it keeps the agent crate focused on agent logic. The agent developer writes their strategy without worrying about traits, zkVM specifics, or kernel integration. The wrapper handles all of that. + +Second, it allows the same agent to be wrapped differently for different purposes. A test wrapper might add logging or instrumentation. A production wrapper might be minimal. Multiple wrappers can exist for the same agent. + +Third, it provides a natural boundary for the imageId. The imageId is computed from the compiled zkVM guest, which includes the wrapper. If you want a new imageId (perhaps to register a new agent version), you create a new wrapper. The agent crate itself doesn't need to change. + +Fourth, it keeps kernel-guest dependencies minimal. The kernel-guest crate doesn't depend on any specific agent—it only depends on the trait definition. This makes the kernel smaller, faster to compile, and easier to audit. + +## From Agent to zkVM Program + +The journey from agent source code to executable zkVM program involves several compilation steps, each producing artifacts that matter for the protocol. + +The agent crate compiles first. During this compilation, a build script (build.rs) computes the agent_code_hash by hashing the agent's source files. This hash is embedded as a constant in the compiled agent library. + +The wrapper crate compiles next. It links against the agent crate and implements `AgentEntrypoint`. The wrapper's `code_hash()` method returns the agent_code_hash from the agent crate. + +The zkvm-guest crate compiles last. This is the actual zkVM entry point—it has a `main()` function that reads input from the zkVM environment, calls the wrapper's `kernel_main()` function, and commits the result to the journal. The zkvm-guest is compiled to a RISC-V ELF binary targeting the RISC Zero zkVM. + +The risc0-methods build process then takes this ELF binary and computes its imageId. The imageId is a cryptographic hash of the binary contents. It uniquely identifies this specific combination of kernel + wrapper + agent. + +At the end of this process, you have: + +- **agent_code_hash**: A hash of the agent source, embedded in the binary +- **ZKVM_GUEST_ELF**: The compiled zkVM guest binary +- **ZKVM_GUEST_ID**: The imageId, a hash of the ELF + +These artifacts are used for deployment. The imageId is registered with the on-chain verifier. The ELF is used by the prover to generate proofs. The agent_code_hash appears in journals and can be verified on-chain. + +## Agent Lifecycle + +An agent's lifecycle in production looks like this: + +1. **Development**: The agent developer writes the agent crate with its `agent_main` function. They test locally using the host-tests framework, which runs the kernel outside the zkVM for fast iteration. + +2. **Integration**: The developer creates a wrapper crate and builds the zkVM guest. They run e2e-tests to verify that proof generation works and produces the expected journal contents. + +3. **Deployment**: The imageId is registered with the KernelExecutionVerifier contract, associating it with a specific agent identifier. The vault is configured to trust this agent. + +4. **Execution**: When the agent needs to run, an off-chain coordinator constructs the `KernelInputV1` with the appropriate inputs, runs the prover to generate a proof, and submits the proof to the vault. + +5. **Verification**: The vault calls the verifier contract, which checks the proof against the registered imageId. If valid, the vault parses the journal and executes the agent's actions. + +6. **Upgrades**: If the agent needs to be updated, the developer modifies the agent crate, creates a new wrapper, builds a new ELF with a new imageId, and registers the new imageId on-chain. The old version can be deprecated or kept active depending on governance decisions. + +Throughout this lifecycle, the separation between agent, wrapper, and kernel remains clear. Each component can evolve independently, and the cryptographic bindings ensure that only the expected combinations can produce valid proofs. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d6f248c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,125 @@ +# Architecture + +The Execution Kernel is the consensus-critical component that defines what constitutes a valid agent execution. It runs inside a RISC Zero zkVM, producing cryptographic proofs that bind an agent's decisions to verifiable commitments. These proofs enable smart contracts to trust off-chain computation without re-executing it. + +This document explains how the system is structured and why each component exists. + +## The Role of the Execution Kernel + +In the broader protocol, capital is held in on-chain vaults. These vaults delegate decision-making to agents—programs that analyze market conditions and produce actions like deposits, withdrawals, or trades. The challenge is trust: how can a vault execute an agent's instructions without the agent having custody of funds, and without the vault needing to understand or re-execute the agent's logic? + +The execution kernel solves this by acting as a verifiable sandbox. An agent runs inside the kernel, which runs inside a zkVM. The zkVM produces a proof that the agent executed correctly according to its own code, that the kernel enforced all protocol constraints, and that the resulting actions are exactly what the agent decided. The vault can verify this proof on-chain and execute the actions with cryptographic certainty that they came from a legitimate execution. + +The kernel itself is deliberately minimal. It decodes inputs, invokes the agent, enforces constraints, and commits the results to a journal. It does not contain business logic, trading strategies, or protocol-specific rules. Those belong to agents. The kernel's job is to be a trusted, deterministic execution environment that agents plug into. + +## Crate Organization + +The repository separates concerns into distinct layers, each with a clear responsibility. + +### Protocol Layer + +The `protocol/` directory contains the foundational types and rules that define the execution protocol itself. + +**kernel-core** provides the canonical data structures: `KernelInputV1`, `KernelJournalV1`, `ActionV1`, and `AgentOutput`. It also implements the deterministic binary codec used to serialize these structures. This codec is consensus-critical—every implementation that interacts with the protocol must encode and decode data identically. The crate deliberately avoids serde and other auto-derivation to maintain byte-level determinism. + +**constraints** implements the constraint engine that validates agent outputs. When an agent produces actions, the constraint engine checks that they conform to protocol rules: action types must be recognized, payloads must be well-formed, and various limits must be respected. The constraint engine can reject an agent's output, causing the execution to produce a Failure status rather than Success. Importantly, constraint violations do not prevent proof generation—they result in a valid proof of a failed execution, which the on-chain verifier can distinguish from a successful one. + +### SDK Layer + +The `sdk/kernel-sdk` crate provides utilities for agent developers. It includes helper functions for constructing actions, working with addresses, and managing the `AgentContext` that the kernel provides to agents. Agent developers import this crate to access these conveniences without depending on kernel internals. + +The SDK also defines the `AgentOutput` type and action construction helpers like `call_action` and `transfer_erc20_action`. These ensure that agents produce well-formed outputs that the kernel and constraint engine can process. + +### Runtime Layer + +The `runtime/` directory contains the components that actually execute inside the zkVM. + +**kernel-guest** is the core execution logic. It defines the `AgentEntrypoint` trait and the `kernel_main_with_agent` function that orchestrates execution. When the zkVM runs, it executes code from this crate. The kernel-guest is agent-agnostic—it contains no references to specific agents. Instead, it accepts any type implementing `AgentEntrypoint` and invokes it generically. + +**risc0-methods** is the RISC Zero build crate. It compiles the zkVM guest program and exports two critical artifacts: `ZKVM_GUEST_ELF` (the compiled binary) and `ZKVM_GUEST_ID` (the imageId, a cryptographic hash of the binary). The imageId uniquely identifies the guest program and is used for on-chain verification. + +### Agents Layer + +The `agents/` directory is where specific agent implementations live. + +**agents/examples/** contains reference agent implementations. The `example-yield-agent` demonstrates a complete agent that deposits into a yield source and withdraws with profits. It serves as both a working example and a test fixture for the protocol. + +**agents/wrappers/** contains binding crates that connect specific agents to the kernel. A wrapper crate implements `AgentEntrypoint` by delegating to a concrete agent's `agent_main` function. This indirection is what makes the kernel agent-agnostic—the kernel depends only on the trait, and the wrapper provides the concrete implementation. + +### Testing Layer + +The `testing/` directory contains test suites that verify the system works correctly. + +**kernel-host-tests** runs unit tests outside the zkVM, testing codec correctness, constraint enforcement, and kernel logic without the overhead of proof generation. + +**e2e-tests** runs full end-to-end tests that generate actual RISC Zero proofs and, optionally, submit them to deployed smart contracts on Sepolia. These tests verify the complete flow from agent execution to on-chain verification. + +## Why Agent-Agnostic? + +The kernel is deliberately designed to have no knowledge of specific agents. This separation exists for several reasons. + +First, it enables agent developers to work independently. An agent developer creates their agent crate, writes a small wrapper, and produces a zkVM guest without modifying any kernel code. They don't need to submit pull requests, wait for reviews, or coordinate with kernel maintainers. + +Second, it ensures that the kernel remains minimal and auditable. The kernel is consensus-critical—any bug could compromise the entire protocol. By keeping agent-specific logic out of the kernel, we reduce the attack surface and make the kernel easier to reason about and verify. + +Third, it creates a clean upgrade path. When the protocol evolves, kernel changes and agent changes can happen independently. An agent can be updated without touching the kernel, and the kernel can be upgraded without breaking existing agents (as long as the `AgentEntrypoint` interface remains stable). + +The mechanism for this separation is Rust's trait system. The kernel defines: + +```rust +pub trait AgentEntrypoint { + fn code_hash(&self) -> [u8; 32]; + fn run(&self, ctx: &AgentContext, opaque_inputs: &[u8]) -> AgentOutput; +} +``` + +Any type implementing this trait can be passed to `kernel_main_with_agent`. The wrapper crates provide these implementations, connecting generic kernel code to specific agent implementations. + +## The ImageId and Agent Binding + +A critical property of the system is that one imageId corresponds to exactly one agent. This binding is what makes on-chain verification meaningful. + +The imageId is a cryptographic hash of the compiled zkVM guest binary. This binary includes the kernel code, the wrapper code, and the agent code—all compiled together into a single ELF. If any of these components change, the imageId changes. + +When a vault is configured, it registers which imageId it trusts for a given agent identifier. When a proof is submitted, the on-chain verifier checks that the proof was generated by a guest with the registered imageId. This ensures that only the expected agent, running in the expected kernel, can produce valid proofs for that vault. + +The agent_code_hash provides an additional layer of binding. Each agent has a build script that computes a hash of its source code at compile time. This hash is embedded in the agent binary and returned by the `AgentEntrypoint::code_hash()` method. The kernel includes this hash in the journal, and the on-chain contracts can verify it matches expectations. + +The relationship between these identifiers is: + +1. **Agent source code** → compiled into agent crate → **agent_code_hash** embedded at build time +2. **Agent crate + wrapper + kernel** → compiled into zkVM guest → **imageId** computed from ELF +3. **imageId** registered on-chain with verifier contract +4. **Proof** ties together: execution inputs → agent decisions → journal containing agent_code_hash +5. **On-chain verification** confirms proof matches registered imageId, journal is well-formed + +This chain of cryptographic commitments ensures that a valid proof could only have been produced by the exact agent code that was registered, running in the exact kernel version that was compiled. + +## Determinism Requirements + +The execution kernel runs in a zkVM, which means every execution must be perfectly deterministic. Given the same inputs, the kernel must produce the same outputs, byte for byte, every time. Any non-determinism would cause proof generation to fail or produce inconsistent results. + +This requirement permeates the entire codebase: + +- No floating-point arithmetic (hardware differences cause divergence) +- No randomness or time-dependent operations +- No hash maps or other unordered collections (iteration order varies) +- Manual binary encoding instead of serde (auto-derive can change between versions) +- Explicit bounds on all loops and allocations (unbounded operations can diverge) +- Checked arithmetic to handle overflow consistently + +The constraint engine and codec are particularly careful about determinism. The codec uses explicit little-endian encoding with length prefixes, rejecting any input with trailing bytes. The constraint engine processes actions in order without sorting or reordering. + +These constraints may seem burdensome, but they're essential. The zkVM can only prove what it can reproduce, and reproducibility requires determinism. + +## Failure Handling + +The kernel distinguishes between two kinds of failures. + +**Soft failures** occur when an agent produces output that violates constraints. The constraint engine detects the violation and the kernel produces a journal with `execution_status = Failure`. This is still a valid proof—it proves that the agent tried to do something invalid. The on-chain verifier can accept this proof but recognize that no actions should be executed. + +**Hard failures** occur when something is fundamentally wrong: the input is malformed, the protocol version is unsupported, or the agent_code_hash doesn't match. In these cases, the kernel panics, aborting proof generation entirely. No valid proof is produced, so nothing can be submitted on-chain. + +This distinction is important for the protocol. Soft failures are expected—agents might occasionally produce invalid outputs, and the system handles this gracefully. Hard failures indicate bugs or attacks, and the system correctly refuses to produce proofs for them. + +The journal always contains commitments to the inputs (`input_commitment`) and, for successful executions, commitments to the outputs (`action_commitment`). For failed executions, the action commitment is set to a well-known constant (`EMPTY_OUTPUT_COMMITMENT`). This allows on-chain contracts to verify that a failure occurred without needing to parse the agent's attempted actions. diff --git a/docs/building-an-agent.md b/docs/building-an-agent.md new file mode 100644 index 0000000..fd6f87e --- /dev/null +++ b/docs/building-an-agent.md @@ -0,0 +1,344 @@ +# Building an Agent + +This document walks through the process of creating an agent from scratch. By the end, you will understand what code to write, how the pieces fit together, and what artifacts you'll produce for deployment. + +We'll follow the natural order of development: first the agent logic, then the build infrastructure for the code hash, then the wrapper that connects to the kernel, and finally the zkVM guest that produces the imageId. + +## Setting Up Your Agent Crate + +An agent crate is a standard Rust library crate. Create it with cargo: + +```bash +cargo new --lib my-agent +``` + +Your agent will depend on two crates from the execution kernel: `kernel-core` for the protocol types, and `kernel-sdk` for helper functions. Add these to your Cargo.toml. + +The agent crate should compile for both native targets (for testing) and the RISC-V target (for zkVM execution). Avoid dependencies that don't support `no_std` or that introduce non-determinism. Common pitfalls include crates that use HashMap (unordered iteration), floating-point math, or system time. + +## Implementing agent_main + +The core of your agent is a single function: + +```rust +use kernel_sdk::agent::AgentContext; +use kernel_core::AgentOutput; + +pub fn agent_main(ctx: &AgentContext, opaque_inputs: &[u8]) -> AgentOutput { + // Your logic here +} +``` + +This function receives two arguments. The `AgentContext` contains metadata about the execution—most importantly, the `agent_id` that identifies which vault or account this execution is for. The `opaque_inputs` slice contains whatever data the caller provided, in whatever format you define. + +Your function returns `AgentOutput`, which contains a vector of actions. If your agent decides to do nothing, return an output with an empty actions vector. If your agent wants to execute something, construct the appropriate actions and return them. + +The kernel-sdk provides helpers for common action types. To transfer ERC20 tokens: + +```rust +use kernel_sdk::prelude::*; + +let action = transfer_erc20_action(token_address, recipient, amount); +``` + +To call an arbitrary contract: + +```rust +let action = call_action(target, value, &calldata); +``` + +These functions handle the encoding details so you don't have to worry about the binary format. + +A minimal agent that always does nothing would be: + +```rust +pub fn agent_main(_ctx: &AgentContext, _opaque_inputs: &[u8]) -> AgentOutput { + AgentOutput { actions: vec![] } +} +``` + +A more realistic agent would parse the opaque inputs, make decisions based on that data, and construct appropriate actions. The example-yield-agent in the repository demonstrates this pattern: it parses addresses and amounts from the input bytes, constructs deposit and withdraw actions, and returns them. + +## Parsing Your Inputs + +The opaque_inputs slice is entirely your responsibility. The kernel doesn't know or care what format you use. You might use a simple fixed-layout binary format, or something more sophisticated. + +For a fixed layout, you'd define your expected structure and parse it directly: + +```rust +if opaque_inputs.len() != 48 { + // Invalid input length, return empty output + return AgentOutput { actions: vec![] }; +} + +let vault_address: [u8; 20] = opaque_inputs[0..20].try_into().unwrap(); +let target_address: [u8; 20] = opaque_inputs[20..40].try_into().unwrap(); +let amount = u64::from_le_bytes(opaque_inputs[40..48].try_into().unwrap()); +``` + +The key principle is defensive parsing. If the input is malformed, return an empty output rather than panicking. A panic aborts proof generation entirely, which may not be what you want. An empty output produces a valid proof showing the agent chose to do nothing, which the caller can handle appropriately. + +## The Code Hash Build Script + +Every agent needs a build script that computes the agent_code_hash. This hash is embedded in the compiled binary and serves as a cryptographic commitment to the agent's source code. + +Create a `build.rs` file in your agent crate: + +```rust +use sha2::{Digest, Sha256}; +use std::{env, fs, path::Path}; + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let src_dir = Path::new(&manifest_dir).join("src"); + + // Collect all .rs files in src/ + let mut source_files: Vec<_> = fs::read_dir(&src_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs")) + .map(|e| e.path()) + .collect(); + source_files.sort(); + + // Hash the contents + let mut hasher = Sha256::new(); + for path in &source_files { + let contents = fs::read_to_string(path).unwrap(); + hasher.update(path.file_name().unwrap().to_string_lossy().as_bytes()); + hasher.update(contents.as_bytes()); + } + let hash = hasher.finalize(); + + // Generate the constant + let hash_hex = hex::encode(&hash); + let hash_bytes: Vec = hash.iter().map(|b| format!("0x{:02x}", b)).collect(); + + let out_dir = env::var("OUT_DIR").unwrap(); + let dest = Path::new(&out_dir).join("agent_code_hash.rs"); + + fs::write( + &dest, + format!( + "pub const AGENT_CODE_HASH: [u8; 32] = [{}];\n", + hash_bytes.join(", ") + ), + ) + .unwrap(); + + println!("cargo:warning=AGENT_CODE_HASH: {}", hash_hex); + println!("cargo:rerun-if-changed=src/"); +} +``` + +Then include the generated constant in your lib.rs: + +```rust +include!(concat!(env!("OUT_DIR"), "/agent_code_hash.rs")); +``` + +This makes `AGENT_CODE_HASH` available as a public constant. The kernel wrapper will use this to implement the `AgentEntrypoint::code_hash()` method. + +The hash changes whenever your source files change. This is intentional—it ensures that different versions of your agent have different code hashes, which can be verified on-chain. + +## Creating the Wrapper Crate + +The wrapper crate connects your agent to the kernel. It implements `AgentEntrypoint` and provides a convenience function for running the kernel with your agent. + +Create a new crate, typically named something like `kernel-guest-binding-myagent`: + +```bash +cargo new --lib kernel-guest-binding-myagent +``` + +The wrapper depends on your agent crate, the kernel-guest crate, and kernel-core: + +```toml +[dependencies] +kernel-guest = { path = "../runtime/kernel-guest" } +kernel-core = { path = "../protocol/kernel-core" } +kernel-sdk = { path = "../sdk/kernel-sdk" } +my-agent = { path = "../agents/examples/my-agent" } +``` + +The implementation is straightforward: + +```rust +use kernel_guest::AgentEntrypoint; +use kernel_sdk::agent::AgentContext; +use kernel_core::AgentOutput; + +pub use my_agent::AGENT_CODE_HASH; + +pub struct MyAgentWrapper; + +impl AgentEntrypoint for MyAgentWrapper { + fn code_hash(&self) -> [u8; 32] { + my_agent::AGENT_CODE_HASH + } + + fn run(&self, ctx: &AgentContext, opaque_inputs: &[u8]) -> AgentOutput { + my_agent::agent_main(ctx, opaque_inputs) + } +} + +pub fn kernel_main(input_bytes: &[u8]) -> Result, kernel_guest::KernelError> { + kernel_guest::kernel_main_with_agent(input_bytes, &MyAgentWrapper) +} +``` + +The wrapper re-exports `AGENT_CODE_HASH` so consumers can access it. It implements the trait by delegating to your agent's `agent_main`. And it provides `kernel_main` as a convenience for the zkVM guest. + +You can also provide a version that accepts custom constraints: + +```rust +pub fn kernel_main_with_constraints( + input_bytes: &[u8], + constraints: &ConstraintSetV1, +) -> Result, kernel_guest::KernelError> { + kernel_guest::kernel_main_with_agent_and_constraints(input_bytes, &MyAgentWrapper, constraints) +} +``` + +## The zkVM Guest Entry Point + +The final piece is the zkVM guest crate. This is what actually runs inside the RISC Zero zkVM. It's typically located in `risc0-methods/zkvm-guest/` and has a special Cargo.toml that targets the zkVM. + +The guest's main.rs is minimal: + +```rust +#![no_main] + +risc0_zkvm::guest::entry!(main); + +fn main() { + use risc0_zkvm::guest::env; + + let input_bytes: Vec = env::read(); + + match kernel_guest_binding_myagent::kernel_main(&input_bytes) { + Ok(journal_bytes) => { + env::commit_slice(&journal_bytes); + } + Err(error) => { + panic!("Kernel execution failed: {:?}", error); + } + } +} +``` + +The guest reads input from the zkVM environment, calls the kernel through your wrapper, and commits the resulting journal. If the kernel returns an error (a hard failure), the guest panics, aborting proof generation. + +The risc0-methods crate has a build.rs that compiles this guest and produces the ELF binary and imageId: + +```rust +risc0_build::embed_methods(); +``` + +This generates `ZKVM_GUEST_ELF` and `ZKVM_GUEST_ID` constants that you can use in your host-side code. + +## Understanding the ImageId + +The imageId is a 32-byte hash that uniquely identifies your compiled zkVM guest. It's computed from the ELF binary, which includes your agent code, the wrapper code, the kernel code, and all dependencies. + +The imageId is what you register on-chain. When you submit a proof, the on-chain verifier checks that the proof was generated by a guest with the expected imageId. This is what makes the system secure—a malicious actor cannot substitute a different agent because any change would result in a different imageId. + +ImageId stability matters for deployment. If you recompile your zkVM guest and get a different imageId, you'll need to register the new imageId on-chain. This can happen due to: + +- Changes to your agent code +- Changes to the kernel or wrapper +- Changes to dependencies (version updates) +- Different compiler versions +- Different build environments + +For reproducible builds, RISC Zero provides Docker-based compilation: + +```bash +RISC0_USE_DOCKER=1 cargo build +``` + +This ensures that the same source code produces the same ELF and imageId regardless of your local environment. + +## What You End Up With + +After building, you have three critical artifacts: + +**AGENT_CODE_HASH** is a 32-byte hash of your agent source code, embedded in your agent crate. It identifies your agent's logic independently of the kernel version. + +**ZKVM_GUEST_ELF** is the compiled zkVM guest binary. You provide this to the prover when generating proofs. + +**ZKVM_GUEST_ID** is the imageId, a 32-byte hash of the ELF. You register this on-chain to authorize your agent. + +These three values appear in different places: + +- The imageId is registered with the `KernelExecutionVerifier` contract +- The agent_code_hash appears in every journal your agent produces +- The ELF is used off-chain by whoever runs the prover + +## Testing Your Agent + +Before deploying, test your agent at multiple levels. + +Unit tests in your agent crate can test the `agent_main` function directly: + +```rust +#[test] +fn test_agent_produces_expected_actions() { + let ctx = AgentContext { /* ... */ }; + let input = /* your test input */; + let output = agent_main(&ctx, &input); + assert_eq!(output.actions.len(), 2); + // ... more assertions +} +``` + +Integration tests using kernel-host-tests run your agent through the full kernel without the zkVM: + +```rust +let input = KernelInputV1 { + agent_code_hash: AGENT_CODE_HASH, + // ... other fields +}; +let journal_bytes = kernel_main(&input.encode().unwrap()).unwrap(); +let journal = KernelJournalV1::decode(&journal_bytes).unwrap(); +assert_eq!(journal.execution_status, ExecutionStatus::Success); +``` + +E2E tests with the `risc0-e2e` feature generate actual proofs: + +```rust +let prover = default_prover(); +let prove_info = prover + .prove_with_opts(env, ZKVM_GUEST_ELF, &ProverOpts::groth16()) + .expect("proof generation failed"); +``` + +And the full on-chain test submits proofs to deployed contracts: + +```bash +cargo test --release -p e2e-tests --features phase3-e2e \ + test_full_e2e_yield_execution -- --ignored --nocapture +``` + +Start with unit tests for fast iteration, then integration tests to verify kernel interaction, and finally E2E tests to confirm the complete flow works. + +## Deployment Checklist + +When you're ready to deploy: + +1. Verify that your agent_code_hash is stable across builds +2. Build the zkVM guest with Docker for reproducible imageId +3. Note the imageId from the build output +4. Register the imageId with the KernelExecutionVerifier contract +5. Configure the vault to trust your agent's identifier +6. Run the on-chain E2E test to verify the full flow + +The imageId registration typically looks like: + +```bash +cast send $VERIFIER_ADDRESS "registerAgent(bytes32,bytes32)" \ + $AGENT_ID $IMAGE_ID \ + --private-key $PRIVATE_KEY --rpc-url $RPC_URL +``` + +Once registered, your agent is live. Proofs generated with your imageId will be accepted by the verifier, and vaults configured to trust your agent can execute its actions. diff --git a/docs/end-to-end-flow.md b/docs/end-to-end-flow.md new file mode 100644 index 0000000..c223dfd --- /dev/null +++ b/docs/end-to-end-flow.md @@ -0,0 +1,175 @@ +# End-to-End Flow + +This document traces the complete lifecycle of an agent execution, from the moment a capital allocator decides to run their strategy through the on-chain execution of the resulting actions. Understanding this flow is essential for integrators who need to know what happens at each stage and why. + +## The Starting Point + +Consider a capital allocator who has deposited funds into a vault. The vault is configured to trust a specific agent, identified by its imageId registered with the on-chain verifier. The allocator wants the agent to analyze current market conditions and potentially rebalance their position. + +The allocator (or an automated system acting on their behalf) gathers the inputs the agent needs: perhaps oracle prices, current positions, risk parameters, or other relevant data. These inputs are serialized into the `opaque_agent_inputs` field of `KernelInputV1`. + +The complete input structure includes: + +- Protocol and kernel versions (for compatibility checking) +- The agent_id (identifying which vault/account this execution is for) +- The agent_code_hash (which agent should run) +- The constraint_set_hash and input_root (for additional verification) +- The execution_nonce (preventing replay attacks) +- The opaque_agent_inputs (the actual data for the agent) + +This structure is encoded using the deterministic binary codec. The encoded bytes are what will be fed into the zkVM. + +## Off-Chain Execution + +The prover—a machine with the RISC Zero toolchain and sufficient computational resources—receives the encoded input and begins proof generation. + +The prover loads the zkVM guest ELF (the compiled binary containing the kernel and agent) and initializes the zkVM environment with the input bytes. It then executes the guest program: + +1. The zkVM guest reads the input bytes from its environment +2. It calls the kernel's entry point, passing the input +3. The kernel decodes and validates `KernelInputV1` +4. The kernel verifies that the embedded agent_code_hash matches what was declared in the input +5. The kernel computes the input_commitment (SHA-256 of the raw input bytes) +6. The kernel invokes the agent through the `AgentEntrypoint` trait +7. The agent parses its opaque inputs and produces an `AgentOutput` +8. The kernel runs the constraint engine on the agent's output +9. If constraints pass, the kernel computes the action_commitment (SHA-256 of the encoded output) +10. The kernel constructs `KernelJournalV1` with all commitments and the execution status +11. The kernel encodes the journal and returns it +12. The zkVM guest commits the journal bytes to the proof + +While this execution happens, the zkVM records every operation. After execution completes, the prover uses this record to construct a cryptographic proof—specifically, a Groth16 proof that can be verified efficiently on-chain. + +The proof generation is computationally intensive, potentially taking minutes for complex executions. The result is a receipt containing the proof (called the "seal") and the journal (the public outputs of the computation). + +## What the Journal Contains + +The journal is the publicly visible output of the zkVM execution. It's what the on-chain verifier and vault will use to determine what happened. The journal contains: + +**Protocol and kernel versions** allow on-chain contracts to reject proofs from incompatible protocol versions. This provides a clean upgrade path—old proofs aren't valid for new protocol versions. + +**Identity fields** (agent_id, agent_code_hash, constraint_set_hash, input_root, execution_nonce) are copied from the input and appear in the journal. This allows on-chain verification that the proof corresponds to the expected execution context. + +**Input commitment** is the SHA-256 hash of the raw input bytes. Anyone who knows what inputs were provided can verify this matches. This binds the proof to specific inputs without revealing them on-chain. + +**Action commitment** is the SHA-256 hash of the encoded `AgentOutput`. For successful executions, this commits to exactly what actions the agent produced. For failed executions, this is set to the well-known empty output commitment. + +**Execution status** indicates whether the execution succeeded or failed. Success means the agent produced valid output that passed constraint checking. Failure means the agent's output violated constraints. + +The journal is exactly 209 bytes—a fixed size that makes on-chain parsing straightforward and gas-efficient. + +## Proof Submission + +The off-chain coordinator now has a receipt containing the Groth16 proof and the journal. To execute the agent's actions, it must submit this to the vault. + +The submission call includes three pieces of data: + +1. **The journal** (209 bytes) — the kernel's output +2. **The seal** (260 bytes with selector) — the Groth16 proof +3. **The agent output bytes** — the actual `AgentOutput` that the agent produced + +The agent output bytes aren't technically necessary for verification—the journal contains a commitment to them. But the vault needs to know what actions to execute, and providing the full output is more gas-efficient than having the agent embed full action data in the journal. + +## On-Chain Verification + +The vault receives the submission and begins verification. + +First, it calls the KernelExecutionVerifier contract with the journal and seal. The verifier: + +1. Extracts the agent_id from the journal +2. Looks up the registered imageId for that agent +3. Calls the RISC Zero verifier router with the seal, journal, and expected imageId +4. The router verifies the Groth16 proof + +If verification fails, the transaction reverts. No actions are executed. + +If verification succeeds, the proof is valid. The verifier has cryptographic certainty that: + +- The journal was produced by a zkVM guest with the registered imageId +- That guest executed the kernel with the agent bound to that imageId +- The kernel ran to completion and produced this journal honestly + +## Parsing and Validation + +After proof verification, the vault parses the journal using the KernelOutputParser library: + +```solidity +KernelOutputParser.ParsedJournal memory parsed = KernelOutputParser.parse(journal); +``` + +The parser extracts all fields from the journal's binary format. The vault then performs additional validation: + +- Does the agent_id match this vault's configured agent? +- Is the execution_nonce correct (prevents replay)? +- Is the execution_status Success? + +If any of these checks fail, the transaction reverts or handles the failure gracefully. + +For successful executions, the vault verifies that the provided agent output bytes hash to the action_commitment in the journal: + +```solidity +require( + sha256(agentOutputBytes) == parsed.actionCommitment, + "Action commitment mismatch" +); +``` + +This ensures that the agent output bytes haven't been tampered with—they're exactly what the agent produced inside the zkVM. + +## Action Execution + +The vault now has verified, authenticated actions from the agent. It decodes the `AgentOutput` structure and iterates through the actions. + +For each action, the vault checks the action type and executes accordingly: + +**CALL actions** result in the vault calling the target address with the specified value and calldata. This is how agents interact with DeFi protocols—depositing into yield sources, executing swaps, managing positions. + +**TRANSFER_ERC20 actions** result in the vault transferring tokens. The vault parses the token address, recipient, and amount from the payload and calls the token's transfer function. + +Each action type has specific validation. CALL actions verify that the target is properly formatted (the upper 12 bytes of the 32-byte target must be zero, indicating an Ethereum address). TRANSFER_ERC20 actions verify the payload contains valid addresses and amounts. + +If any action fails (perhaps the vault has insufficient balance, or the target contract reverts), the entire transaction reverts. This atomicity ensures that either all actions execute or none do—there's no partial state. + +## Failure Cases + +Not every execution succeeds, and the system handles failures at multiple levels. + +**Constraint violations** occur when the agent produces output that doesn't conform to the constraint set. Perhaps the agent tried to exceed position limits, or produced an unrecognized action type. The kernel detects this, sets `execution_status = Failure`, and returns a journal with the empty output commitment. The proof is valid—it proves that the agent tried something invalid. The on-chain vault can verify this proof but will see the Failure status and decline to execute any actions. + +**Hard failures** occur when something is fundamentally wrong—malformed input, wrong protocol version, agent_code_hash mismatch. The kernel panics, proof generation aborts, and no valid proof is produced. This is appropriate because these failures indicate bugs or attacks, not legitimate agent behavior. + +**On-chain failures** can occur even with valid proofs. Perhaps the nonce is wrong (replay attempt), or the vault's state has changed since the proof was generated, or the agent's actions are no longer possible. The transaction reverts, but the proof remains valid—it could potentially be resubmitted if the on-chain state permits. + +The important property is that the zkVM guarantees are preserved across all cases. A valid proof always means the kernel executed correctly with the declared agent. The execution status tells you whether that execution succeeded or failed. And the commitments bind everything together cryptographically. + +## The Security Model + +This end-to-end flow achieves several security properties: + +**No custody transfer.** The agent never has custody of funds. It produces instructions that the vault executes. If an agent is buggy or malicious, the worst it can do is produce invalid instructions (which fail constraint checks) or valid instructions that the capital allocator didn't intend (which is a governance/configuration problem, not a security breach). + +**Verifiable execution.** Every claim is backed by cryptographic proof. The proof covers the entire execution—input parsing, agent logic, constraint checking, output encoding. There's no trusted intermediary who could lie about what happened. + +**Deterministic replay.** Given the input bytes and the imageId, anyone can re-execute the computation and verify they get the same result. The zkVM doesn't add any magic—it just proves that standard computation happened correctly. + +**Atomic commitment.** The journal commits to both inputs and outputs atomically. You can't have a valid proof that commits to different inputs than were actually used, or different outputs than were actually produced. + +**Replay protection.** The execution_nonce ensures that each proof can only be used once. Even if an attacker obtains a valid proof, they can't replay it—the vault will reject the stale nonce. + +**Upgrade safety.** Protocol versions in the journal allow contracts to reject proofs from deprecated kernel versions. ImageId registration allows precise control over which agent versions are authorized. + +## Practical Considerations + +Several practical aspects affect how the flow works in production. + +**Proof generation time** varies with execution complexity and prover hardware. Simple agents might prove in a minute; complex ones might take longer. The off-chain coordinator needs appropriate hardware and timeout handling. + +**Gas costs** for on-chain verification are significant but fixed. The Groth16 verification is constant-cost regardless of what the agent did. Parsing and action execution add variable costs depending on the number and complexity of actions. + +**Latency** from decision to execution includes proof generation time plus transaction confirmation time. For time-sensitive strategies, this may be a constraint. The architecture supports batching multiple independent executions if needed. + +**State synchronization** matters because the agent runs with a snapshot of inputs, but the on-chain state can change before the proof is submitted. Agents should be designed to handle this—perhaps by including slippage tolerances or validity windows in their logic. + +**Error handling** on the coordinator side should distinguish between proof generation failures (retry with same inputs), constraint violations (review agent logic), and on-chain failures (check state, possibly retry). + +The system is designed to make these practical considerations manageable while maintaining the core security properties. The cryptographic guarantees don't depend on the coordinator being trusted—they hold as long as the zkVM and on-chain verifier are correct. diff --git a/docs/workflow.png b/docs/workflow.png deleted file mode 100644 index e52a9b0..0000000 Binary files a/docs/workflow.png and /dev/null differ diff --git a/docs/workflow_e2e.png b/docs/workflow_e2e.png new file mode 100644 index 0000000..e77c393 Binary files /dev/null and b/docs/workflow_e2e.png differ