Add envelope verification token: keyed SipHash-2-4 MAC stamps envelope.yaml
- Key management: 32-byte random key at ~/.g3/verification.key (chmod 600) - Token format: g3v1:<base64(SipHash-2-4 of canonical_facts + NUL + canonical_rulespec)> - stamp_envelope() called only when all rulespec predicates pass - verify_token() for cross-process validation - ActionEnvelope.verified field (Option<String>, skip_serializing_if none) - Token never shown to LLM, only written to envelope.yaml - Zero new dependencies (uses std SipHasher, existing rand/base64) - 12 unit tests covering determinism, tamper detection, backward compat
This commit is contained in:
@@ -7,13 +7,17 @@
|
|||||||
//! 3. Runs `verify_envelope()` which compiles the rulespec and executes
|
//! 3. Runs `verify_envelope()` which compiles the rulespec and executes
|
||||||
//! datalog verification in shadow form (results written to files, not
|
//! datalog verification in shadow form (results written to files, not
|
||||||
//! injected into context)
|
//! injected into context)
|
||||||
|
//! 4. If all predicates pass, stamps the envelope with a verification token
|
||||||
|
//! (`verified: "g3v1:<base64>"`) that proves deterministic checks passed.
|
||||||
//!
|
//!
|
||||||
//! This creates a clear happens-before edge: envelope creation + verification
|
//! This creates a clear happens-before edge: envelope creation + verification
|
||||||
//! must complete before `plan_verify()` (triggered on plan completion) checks
|
//! must complete before `plan_verify()` (triggered on plan completion) checks
|
||||||
//! that the envelope exists.
|
//! that the envelope exists.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use std::path::Path;
|
use base64::Engine;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::paths::get_session_logs_dir;
|
use crate::paths::get_session_logs_dir;
|
||||||
@@ -23,9 +27,209 @@ use crate::ToolCall;
|
|||||||
use super::executor::ToolContext;
|
use super::executor::ToolContext;
|
||||||
use super::invariants::{
|
use super::invariants::{
|
||||||
format_envelope_markdown, get_envelope_path, read_envelope, read_rulespec,
|
format_envelope_markdown, get_envelope_path, read_envelope, read_rulespec,
|
||||||
write_envelope, ActionEnvelope,
|
write_envelope, ActionEnvelope, Rulespec,
|
||||||
};
|
};
|
||||||
use super::datalog::{compile_rulespec, extract_facts, execute_rules, format_datalog_program, format_datalog_results};
|
use super::datalog::{
|
||||||
|
compile_rulespec, execute_rules, extract_facts, format_datalog_program,
|
||||||
|
format_datalog_results,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Verification Key Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const VERIFICATION_KEY_FILENAME: &str = "verification.key";
|
||||||
|
const VERIFICATION_KEY_LEN: usize = 32;
|
||||||
|
const TOKEN_PREFIX: &str = "g3v1:";
|
||||||
|
|
||||||
|
/// Get the path to the global verification key: `~/.g3/verification.key`
|
||||||
|
fn get_verification_key_path() -> Result<PathBuf> {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
|
.map_err(|_| anyhow!("Cannot determine home directory"))?;
|
||||||
|
Ok(PathBuf::from(home).join(".g3").join(VERIFICATION_KEY_FILENAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read or create the verification key at `~/.g3/verification.key`.
|
||||||
|
///
|
||||||
|
/// - If the key file exists, reads and returns it.
|
||||||
|
/// - If it doesn't exist, generates 32 random bytes, writes them with
|
||||||
|
/// mode 600 (Unix), and returns the key.
|
||||||
|
/// - The key is raw bytes, never logged or shown to the LLM.
|
||||||
|
pub fn get_or_create_verification_key() -> Result<Vec<u8>> {
|
||||||
|
let path = get_verification_key_path()?;
|
||||||
|
|
||||||
|
// If key exists, read and return it
|
||||||
|
if path.exists() {
|
||||||
|
let key = std::fs::read(&path)?;
|
||||||
|
if key.len() == VERIFICATION_KEY_LEN {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
// Key file is wrong size — regenerate
|
||||||
|
debug!("Verification key has wrong size ({}), regenerating", key.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ~/.g3/ directory exists
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 32 random bytes
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let mut key = vec![0u8; VERIFICATION_KEY_LEN];
|
||||||
|
rng.fill(&mut key[..]);
|
||||||
|
|
||||||
|
// Write key file
|
||||||
|
std::fs::write(&path, &key)?;
|
||||||
|
|
||||||
|
// Set permissions to 600 (owner read/write only) on Unix
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let perms = std::fs::Permissions::from_mode(0o600);
|
||||||
|
std::fs::set_permissions(&path, perms)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Generated new verification key at {}", path.display());
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the verification key. Returns None if it doesn't exist.
|
||||||
|
pub fn read_verification_key() -> Result<Option<Vec<u8>>> {
|
||||||
|
let path = get_verification_key_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let key = std::fs::read(&path)?;
|
||||||
|
if key.len() != VERIFICATION_KEY_LEN {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Computation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Compute a canonical YAML representation of the envelope facts.
|
||||||
|
///
|
||||||
|
/// This produces a deterministic string by sorting keys, ensuring
|
||||||
|
/// the same facts always produce the same canonical form.
|
||||||
|
fn canonical_facts_yaml(envelope: &ActionEnvelope) -> String {
|
||||||
|
// Use serde_yaml which produces deterministic output for the same structure.
|
||||||
|
// We serialize only the facts (not the verified field) to get a stable input.
|
||||||
|
let mut sorted_facts: Vec<(&String, &serde_yaml::Value)> =
|
||||||
|
envelope.facts.iter().collect();
|
||||||
|
sorted_facts.sort_by_key(|(k, _)| *k);
|
||||||
|
|
||||||
|
let mut mapping = serde_yaml::Mapping::new();
|
||||||
|
for (k, v) in sorted_facts {
|
||||||
|
mapping.insert(
|
||||||
|
serde_yaml::Value::String(k.clone()),
|
||||||
|
v.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serde_yaml::to_string(&mapping).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a canonical YAML representation of the rulespec.
|
||||||
|
fn canonical_rulespec_yaml(rulespec: &Rulespec) -> String {
|
||||||
|
serde_yaml::to_string(rulespec).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a verification token using a keyed SipHash MAC.
|
||||||
|
///
|
||||||
|
/// The token is computed as:
|
||||||
|
/// SipHash-2-4(key[0..16], canonical_facts || "\x00" || canonical_rulespec)
|
||||||
|
///
|
||||||
|
/// Then encoded as: `g3v1:<base64(8-byte hash)>`
|
||||||
|
///
|
||||||
|
/// This is a keyed PRF (pseudo-random function) — not a plain hash.
|
||||||
|
/// The key is never exposed to the LLM, making the token unguessable.
|
||||||
|
pub fn mint_token(key: &[u8], envelope: &ActionEnvelope, rulespec: &Rulespec) -> String {
|
||||||
|
let facts_yaml = canonical_facts_yaml(envelope);
|
||||||
|
let rulespec_yaml = canonical_rulespec_yaml(rulespec);
|
||||||
|
|
||||||
|
let hash = compute_keyed_hash(key, &facts_yaml, &rulespec_yaml);
|
||||||
|
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash.to_le_bytes());
|
||||||
|
|
||||||
|
format!("{}{}", TOKEN_PREFIX, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a keyed SipHash over the canonical content.
|
||||||
|
///
|
||||||
|
/// Uses the first 16 bytes of the key as SipHash key (k0, k1).
|
||||||
|
/// The message is: facts_yaml + NUL separator + rulespec_yaml.
|
||||||
|
fn compute_keyed_hash(key: &[u8], facts_yaml: &str, rulespec_yaml: &str) -> u64 {
|
||||||
|
// Extract k0 and k1 from the key (first 16 bytes)
|
||||||
|
let k0 = if key.len() >= 8 {
|
||||||
|
u64::from_le_bytes(key[0..8].try_into().unwrap())
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let k1 = if key.len() >= 16 {
|
||||||
|
u64::from_le_bytes(key[8..16].try_into().unwrap())
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
#[allow(deprecated)] // SipHasher is deprecated but we need keyed hashing
|
||||||
|
let mut hasher = std::hash::SipHasher::new_with_keys(k0, k1);
|
||||||
|
facts_yaml.hash(&mut hasher);
|
||||||
|
0u8.hash(&mut hasher); // NUL separator
|
||||||
|
rulespec_yaml.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Verification (cross-process)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Verify the token in an envelope against the verification key and rulespec.
|
||||||
|
///
|
||||||
|
/// This is the cross-process verification entry point. It:
|
||||||
|
/// 1. Reads `~/.g3/verification.key`
|
||||||
|
/// 2. Reads the envelope from the session
|
||||||
|
/// 3. Reads the rulespec from the working directory
|
||||||
|
/// 4. Recomputes the token and compares
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// - `Ok(true)` if the token matches
|
||||||
|
/// - `Ok(false)` if the token doesn't match or is missing
|
||||||
|
/// - `Err(...)` if required files are missing
|
||||||
|
pub fn verify_token(session_id: &str, working_dir: &Path) -> Result<bool> {
|
||||||
|
// Read verification key
|
||||||
|
let key = match read_verification_key()? {
|
||||||
|
Some(k) => k,
|
||||||
|
None => return Err(anyhow!("Verification key not found at ~/.g3/verification.key")),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read envelope
|
||||||
|
let envelope = match read_envelope(session_id)? {
|
||||||
|
Some(e) => e,
|
||||||
|
None => return Err(anyhow!("Envelope not found for session {}", session_id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check that envelope has a verified field
|
||||||
|
let stored_token = match &envelope.verified {
|
||||||
|
Some(t) => t.clone(),
|
||||||
|
None => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read rulespec
|
||||||
|
let rulespec = match read_rulespec(working_dir)? {
|
||||||
|
Some(rs) => rs,
|
||||||
|
None => return Err(anyhow!("Rulespec not found at {}/analysis/rulespec.yaml", working_dir.display())),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recompute token (using envelope without the verified field for computation)
|
||||||
|
let mut clean_envelope = envelope.clone();
|
||||||
|
clean_envelope.verified = None;
|
||||||
|
let expected_token = mint_token(&key, &clean_envelope, &rulespec);
|
||||||
|
|
||||||
|
Ok(stored_token == expected_token)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tool Implementation
|
// Tool Implementation
|
||||||
@@ -73,7 +277,7 @@ pub async fn execute_write_envelope<W: UiWriter>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the envelope to disk
|
// Write the envelope to disk (without verified token initially)
|
||||||
if let Err(e) = write_envelope(session_id, &envelope) {
|
if let Err(e) = write_envelope(session_id, &envelope) {
|
||||||
return Ok(format!("❌ Failed to write envelope: {}", e));
|
return Ok(format!("❌ Failed to write envelope: {}", e));
|
||||||
}
|
}
|
||||||
@@ -107,6 +311,7 @@ pub async fn execute_write_envelope<W: UiWriter>(
|
|||||||
/// 3. Loads the envelope from the session
|
/// 3. Loads the envelope from the session
|
||||||
/// 4. Extracts facts and runs datalog rules
|
/// 4. Extracts facts and runs datalog rules
|
||||||
/// 5. Writes results to session artifacts (shadow mode - stderr + files)
|
/// 5. Writes results to session artifacts (shadow mode - stderr + files)
|
||||||
|
/// 6. If all predicates pass, stamps the envelope with a verification token
|
||||||
///
|
///
|
||||||
/// Returns a short status string for inclusion in tool output.
|
/// Returns a short status string for inclusion in tool output.
|
||||||
pub fn verify_envelope(session_id: &str, working_dir: &Path) -> String {
|
pub fn verify_envelope(session_id: &str, working_dir: &Path) -> String {
|
||||||
@@ -180,7 +385,21 @@ pub fn verify_envelope(session_id: &str, working_dir: &Path) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If all predicates passed, stamp the envelope with a verification token
|
||||||
|
if result.failed_count == 0 && result.passed_count > 0 {
|
||||||
|
match stamp_envelope(session_id, &envelope, &rulespec) {
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!("🔏 Envelope stamped with verification token");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("⚠️ Failed to stamp envelope: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return a summary for the tool output
|
// Return a summary for the tool output
|
||||||
|
// NOTE: The token value is intentionally NOT included in the output
|
||||||
|
// returned to the LLM. It exists only in the envelope.yaml file.
|
||||||
let summary = if result.failed_count == 0 {
|
let summary = if result.failed_count == 0 {
|
||||||
format!(
|
format!(
|
||||||
"\n✅ Invariant verification: {}/{} passed\n",
|
"\n✅ Invariant verification: {}/{} passed\n",
|
||||||
@@ -198,3 +417,241 @@ pub fn verify_envelope(session_id: &str, working_dir: &Path) -> String {
|
|||||||
|
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stamp an envelope with a verification token and re-write it to disk.
|
||||||
|
///
|
||||||
|
/// This is called only when all rulespec predicates pass. It:
|
||||||
|
/// 1. Gets or creates the verification key
|
||||||
|
/// 2. Computes the token over the canonical facts + rulespec
|
||||||
|
/// 3. Sets the `verified` field on the envelope
|
||||||
|
/// 4. Re-writes the envelope to disk
|
||||||
|
fn stamp_envelope(
|
||||||
|
session_id: &str,
|
||||||
|
envelope: &ActionEnvelope,
|
||||||
|
rulespec: &Rulespec,
|
||||||
|
) -> Result<()> {
|
||||||
|
let key = get_or_create_verification_key()?;
|
||||||
|
|
||||||
|
// Compute token over the original envelope (without any previous verified field)
|
||||||
|
let mut clean_envelope = envelope.clone();
|
||||||
|
clean_envelope.verified = None;
|
||||||
|
let token = mint_token(&key, &clean_envelope, rulespec);
|
||||||
|
|
||||||
|
// Set the verified field and re-write
|
||||||
|
let mut stamped = envelope.clone();
|
||||||
|
stamped.verified = Some(token);
|
||||||
|
write_envelope(session_id, &stamped)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_yaml::Value as YamlValue;
|
||||||
|
|
||||||
|
fn make_test_key() -> Vec<u8> {
|
||||||
|
vec![1u8; VERIFICATION_KEY_LEN]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_different_key() -> Vec<u8> {
|
||||||
|
vec![2u8; VERIFICATION_KEY_LEN]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_test_envelope() -> ActionEnvelope {
|
||||||
|
let mut envelope = ActionEnvelope::new();
|
||||||
|
envelope.add_fact(
|
||||||
|
"feature",
|
||||||
|
serde_yaml::from_str("capabilities: [a, b]\nfile: src/foo.rs").unwrap(),
|
||||||
|
);
|
||||||
|
envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_test_rulespec() -> Rulespec {
|
||||||
|
serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
claims:
|
||||||
|
- name: caps
|
||||||
|
selector: feature.capabilities
|
||||||
|
predicates:
|
||||||
|
- claim: caps
|
||||||
|
rule: exists
|
||||||
|
source: task_prompt
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mint_token_deterministic() {
|
||||||
|
let key = make_test_key();
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
let rulespec = make_test_rulespec();
|
||||||
|
|
||||||
|
let token1 = mint_token(&key, &envelope, &rulespec);
|
||||||
|
let token2 = mint_token(&key, &envelope, &rulespec);
|
||||||
|
|
||||||
|
assert_eq!(token1, token2, "Same inputs must produce same token");
|
||||||
|
assert!(token1.starts_with(TOKEN_PREFIX), "Token must start with g3v1:");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mint_token_different_key() {
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
let rulespec = make_test_rulespec();
|
||||||
|
|
||||||
|
let token1 = mint_token(&make_test_key(), &envelope, &rulespec);
|
||||||
|
let token2 = mint_token(&make_different_key(), &envelope, &rulespec);
|
||||||
|
|
||||||
|
assert_ne!(token1, token2, "Different keys must produce different tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mint_token_different_facts() {
|
||||||
|
let key = make_test_key();
|
||||||
|
let rulespec = make_test_rulespec();
|
||||||
|
|
||||||
|
let envelope1 = make_test_envelope();
|
||||||
|
let mut envelope2 = make_test_envelope();
|
||||||
|
envelope2.add_fact("extra", YamlValue::String("tampered".to_string()));
|
||||||
|
|
||||||
|
let token1 = mint_token(&key, &envelope1, &rulespec);
|
||||||
|
let token2 = mint_token(&key, &envelope2, &rulespec);
|
||||||
|
|
||||||
|
assert_ne!(token1, token2, "Different facts must produce different tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mint_token_different_rulespec() {
|
||||||
|
let key = make_test_key();
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
|
||||||
|
let rulespec1 = make_test_rulespec();
|
||||||
|
let rulespec2: Rulespec = serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
claims:
|
||||||
|
- name: caps
|
||||||
|
selector: feature.capabilities
|
||||||
|
predicates:
|
||||||
|
- claim: caps
|
||||||
|
rule: min_length
|
||||||
|
value: 5
|
||||||
|
source: task_prompt
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token1 = mint_token(&key, &envelope, &rulespec1);
|
||||||
|
let token2 = mint_token(&key, &envelope, &rulespec2);
|
||||||
|
|
||||||
|
assert_ne!(token1, token2, "Different rulespec must produce different tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mint_token_format() {
|
||||||
|
let key = make_test_key();
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
let rulespec = make_test_rulespec();
|
||||||
|
|
||||||
|
let token = mint_token(&key, &envelope, &rulespec);
|
||||||
|
|
||||||
|
assert!(token.starts_with("g3v1:"));
|
||||||
|
let b64_part = &token[5..];
|
||||||
|
// base64 URL-safe no-pad should decode to 8 bytes (u64)
|
||||||
|
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(b64_part)
|
||||||
|
.expect("Token should be valid base64");
|
||||||
|
assert_eq!(decoded.len(), 8, "SipHash produces 8 bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fabricated_token_fails() {
|
||||||
|
let key = make_test_key();
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
let rulespec = make_test_rulespec();
|
||||||
|
|
||||||
|
let real_token = mint_token(&key, &envelope, &rulespec);
|
||||||
|
let fake_token = "g3v1:AAAAAAAAAA".to_string();
|
||||||
|
|
||||||
|
assert_ne!(real_token, fake_token, "Fabricated token should not match");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_envelope_verified_field_serialization() {
|
||||||
|
let mut envelope = make_test_envelope();
|
||||||
|
envelope.verified = Some("g3v1:test123".to_string());
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&envelope).unwrap();
|
||||||
|
assert!(yaml.contains("verified:"), "YAML should contain verified field");
|
||||||
|
assert!(yaml.contains("g3v1:test123"), "YAML should contain token value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_envelope_without_verified_field_backward_compat() {
|
||||||
|
// Simulate an old envelope YAML without verified field
|
||||||
|
let yaml = r#"
|
||||||
|
facts:
|
||||||
|
feature:
|
||||||
|
capabilities: [a, b]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let envelope: ActionEnvelope = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(envelope.verified.is_none(), "Old envelopes should parse with verified=None");
|
||||||
|
assert!(!envelope.facts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_envelope_verified_not_in_to_yaml_value() {
|
||||||
|
let mut envelope = make_test_envelope();
|
||||||
|
envelope.verified = Some("g3v1:test123".to_string());
|
||||||
|
|
||||||
|
let yaml_value = envelope.to_yaml_value();
|
||||||
|
let yaml_str = serde_yaml::to_string(&yaml_value).unwrap();
|
||||||
|
|
||||||
|
assert!(!yaml_str.contains("verified"),
|
||||||
|
"to_yaml_value() must not include verified field");
|
||||||
|
assert!(!yaml_str.contains("g3v1"),
|
||||||
|
"to_yaml_value() must not include token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_envelope_verified_none_not_serialized() {
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
assert!(envelope.verified.is_none());
|
||||||
|
|
||||||
|
let yaml = serde_yaml::to_string(&envelope).unwrap();
|
||||||
|
assert!(!yaml.contains("verified"),
|
||||||
|
"None verified should not appear in YAML");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_facts_yaml_deterministic() {
|
||||||
|
let envelope = make_test_envelope();
|
||||||
|
|
||||||
|
let yaml1 = canonical_facts_yaml(&envelope);
|
||||||
|
let yaml2 = canonical_facts_yaml(&envelope);
|
||||||
|
|
||||||
|
assert_eq!(yaml1, yaml2, "Canonical YAML must be deterministic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_facts_yaml_sorted_keys() {
|
||||||
|
let mut envelope = ActionEnvelope::new();
|
||||||
|
envelope.add_fact("zebra", YamlValue::String("z".to_string()));
|
||||||
|
envelope.add_fact("alpha", YamlValue::String("a".to_string()));
|
||||||
|
envelope.add_fact("middle", YamlValue::String("m".to_string()));
|
||||||
|
|
||||||
|
let yaml = canonical_facts_yaml(&envelope);
|
||||||
|
|
||||||
|
let alpha_pos = yaml.find("alpha").unwrap();
|
||||||
|
let middle_pos = yaml.find("middle").unwrap();
|
||||||
|
let zebra_pos = yaml.find("zebra").unwrap();
|
||||||
|
|
||||||
|
assert!(alpha_pos < middle_pos, "Keys should be sorted");
|
||||||
|
assert!(middle_pos < zebra_pos, "Keys should be sorted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -336,6 +336,11 @@ pub struct ActionEnvelope {
|
|||||||
/// Facts about the completed work (flexible YAML structure)
|
/// Facts about the completed work (flexible YAML structure)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub facts: HashMap<String, YamlValue>,
|
pub facts: HashMap<String, YamlValue>,
|
||||||
|
|
||||||
|
/// Verification token — set by the deterministic verification pipeline.
|
||||||
|
/// Format: "g3v1:<base64>" — proves that rulespec evaluation passed.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub verified: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActionEnvelope {
|
impl ActionEnvelope {
|
||||||
|
|||||||
Reference in New Issue
Block a user