26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
173 lines
6.4 KiB
Rust
173 lines
6.4 KiB
Rust
//! Evidence bundle generator proofs (GL #425 — determinism + content).
|
|
//!
|
|
//! The independent verification side (signature, chain replay, 1-byte
|
|
//! mutation detection) is tested in `packages/leanctx-verify/tests/` —
|
|
//! deliberately against the contract, not against this generator.
|
|
|
|
use lean_ctx::core::audit_trail::{self, AuditEntryData, AuditEventType};
|
|
use lean_ctx::core::evidence_bundle::{BundleSpec, generate};
|
|
use serial_test::serial;
|
|
use std::io::Read;
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn bundle_is_deterministic_and_complete() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
// TODO: Audit that the environment access only happens in single-threaded code.
|
|
unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path()) };
|
|
|
|
for (i, tool) in ["ctx_read", "ctx_search", "ctx_shell"].iter().enumerate() {
|
|
audit_trail::record(AuditEntryData {
|
|
agent_id: "agent-1".into(),
|
|
tool: (*tool).to_string(),
|
|
action: None,
|
|
input_hash: audit_trail::hash_input(&serde_json::Map::new()),
|
|
output_tokens: i as u32,
|
|
role: "developer".into(),
|
|
event_type: AuditEventType::ToolCall,
|
|
});
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
let spec = |out: &std::path::Path| BundleSpec {
|
|
from: (now - chrono::Duration::hours(1)).to_rfc3339(),
|
|
to: (now + chrono::Duration::hours(1)).to_rfc3339(),
|
|
framework: Some("eu-ai-act".to_string()),
|
|
pack: None,
|
|
out: Some(out.to_path_buf()),
|
|
};
|
|
|
|
let out_a = tmp.path().join("a.zip");
|
|
let out_b = tmp.path().join("b.zip");
|
|
let a = generate(&spec(&out_a)).expect("bundle a");
|
|
let b = generate(&spec(&out_b)).expect("bundle b");
|
|
|
|
// Determinism: identical inputs ⇒ identical bytes ⇒ identical hash.
|
|
assert_eq!(
|
|
a.sha256, b.sha256,
|
|
"same input must produce the same bundle hash"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(&out_a).expect("a"),
|
|
std::fs::read(&out_b).expect("b"),
|
|
"byte-identical archives"
|
|
);
|
|
assert_eq!(a.entries, 3);
|
|
|
|
// Completeness: contract layout present.
|
|
let mut archive =
|
|
zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(&out_a).expect("a"))).expect("zip");
|
|
let names: Vec<String> = (0..archive.len())
|
|
.map(|i| archive.by_index(i).expect("entry").name().to_string())
|
|
.collect();
|
|
assert!(names.contains(&"manifest.json".to_string()));
|
|
assert!(names.contains(&"audit/trail.jsonl".to_string()));
|
|
assert!(names.contains(&"coverage/cgb.json".to_string()));
|
|
assert!(names.contains(&"coverage/eu-ai-act.json".to_string()));
|
|
assert!(
|
|
names
|
|
.iter()
|
|
.any(|n| n.starts_with("policies/") && n.ends_with(".resolved.json"))
|
|
);
|
|
|
|
// Manifest invariants: chain bounds match the recorded segment and the
|
|
// manifest carries a signature over a recomputable digest.
|
|
let mut manifest_raw = String::new();
|
|
archive
|
|
.by_name("manifest.json")
|
|
.expect("manifest")
|
|
.read_to_string(&mut manifest_raw)
|
|
.expect("read");
|
|
let manifest: serde_json::Value = serde_json::from_str(&manifest_raw).expect("json");
|
|
assert_eq!(manifest["version"], 1);
|
|
assert_eq!(manifest["chain"]["entries"], 3);
|
|
assert_eq!(manifest["chain"]["anchor_prev_hash"], "genesis");
|
|
assert_eq!(manifest["signing"]["algorithm"], "ed25519");
|
|
assert_eq!(
|
|
manifest["signing"]["signature"].as_str().map(str::len),
|
|
Some(128),
|
|
"ed25519 signature present"
|
|
);
|
|
|
|
// No wall-clock fields: the manifest must not contain a created_at.
|
|
assert!(manifest.get("created_at").is_none());
|
|
|
|
// TODO: Audit that the environment access only happens in single-threaded code.
|
|
unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") };
|
|
}
|
|
|
|
/// Regression (GL #425): concurrent appends from multiple threads/handles
|
|
/// forked the chain when `prev_hash` came from a per-process cache. With the
|
|
/// advisory file lock + tail read, N concurrent writers must produce ONE
|
|
/// valid chain of N entries.
|
|
#[test]
|
|
#[serial]
|
|
fn concurrent_appends_do_not_fork_the_chain() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
// TODO: Audit that the environment access only happens in single-threaded code.
|
|
unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path()) };
|
|
|
|
let threads: Vec<_> = (0..4)
|
|
.map(|t| {
|
|
std::thread::spawn(move || {
|
|
for i in 0..25 {
|
|
audit_trail::record(AuditEntryData {
|
|
agent_id: format!("agent-{t}"),
|
|
tool: format!("tool-{i}"),
|
|
action: None,
|
|
input_hash: audit_trail::hash_input(&serde_json::Map::new()),
|
|
output_tokens: i,
|
|
role: "developer".into(),
|
|
event_type: AuditEventType::ToolCall,
|
|
});
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
for t in threads {
|
|
t.join().expect("thread");
|
|
}
|
|
|
|
let chain = audit_trail::verify_chain();
|
|
assert_eq!(chain.total_entries, 100, "all appends persisted");
|
|
assert!(
|
|
chain.valid,
|
|
"no fork: first_invalid_at = {:?}",
|
|
chain.first_invalid_at
|
|
);
|
|
|
|
// TODO: Audit that the environment access only happens in single-threaded code.
|
|
unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") };
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn empty_period_is_an_error_not_an_empty_attestation() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
// TODO: Audit that the environment access only happens in single-threaded code.
|
|
unsafe { std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path()) };
|
|
|
|
audit_trail::record(AuditEntryData {
|
|
agent_id: "agent-1".into(),
|
|
tool: "ctx_read".into(),
|
|
action: None,
|
|
input_hash: audit_trail::hash_input(&serde_json::Map::new()),
|
|
output_tokens: 1,
|
|
role: "developer".into(),
|
|
event_type: AuditEventType::ToolCall,
|
|
});
|
|
|
|
let spec = BundleSpec {
|
|
from: "1999-01-01T00:00:00Z".to_string(),
|
|
to: "1999-01-02T00:00:00Z".to_string(),
|
|
framework: None,
|
|
pack: None,
|
|
out: Some(tmp.path().join("never.zip")),
|
|
};
|
|
let err = generate(&spec).expect_err("empty period must fail");
|
|
assert!(err.contains("no audit entries"), "{err}");
|
|
|
|
// TODO: Audit that the environment access only happens in single-threaded code.
|
|
unsafe { std::env::remove_var("LEAN_CTX_DATA_DIR") };
|
|
}
|