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
213 lines
8.0 KiB
Rust
213 lines
8.0 KiB
Rust
//! Mutation tests (GL #425 AC 1): a synthetic spec-conformant bundle
|
|
//! verifies; ANY single-byte manipulation — payload, chain, manifest —
|
|
//! is detected. The bundle is built here from the contract text alone,
|
|
//! independent of the engine's generator.
|
|
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::Write;
|
|
|
|
// The verifier crate is a binary; pull the core in via path include for
|
|
// testing (no library target on purpose — auditors get one executable).
|
|
#[path = "../src/verify.rs"]
|
|
mod verify;
|
|
|
|
use verify::{verify_bundle, StepStatus};
|
|
|
|
fn sha256_hex(bytes: &[u8]) -> String {
|
|
let mut h = Sha256::new();
|
|
h.update(bytes);
|
|
format!("{:x}", h.finalize())
|
|
}
|
|
|
|
fn hex_encode(bytes: &[u8]) -> String {
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
struct Fixture {
|
|
bundle: Vec<u8>,
|
|
pubkey_hex: String,
|
|
}
|
|
|
|
/// Build a minimal, fully spec-conformant bundle: 3 chained + signed audit
|
|
/// entries, one policy file, signed manifest, deterministic ZIP.
|
|
fn build_fixture() -> Fixture {
|
|
let signing = SigningKey::from_bytes(&[7u8; 32]);
|
|
let pubkey_hex = hex_encode(signing.verifying_key().as_bytes());
|
|
|
|
// audit chain
|
|
let mut entries = Vec::new();
|
|
let mut prev = "genesis".to_string();
|
|
for (i, tool) in ["ctx_read", "ctx_search", "ctx_shell"].iter().enumerate() {
|
|
let data = serde_json::json!({
|
|
"agent_id": "agent-1",
|
|
"tool": tool,
|
|
"action": null,
|
|
"input_hash": sha256_hex(b"{}"),
|
|
"output_tokens": i as u64,
|
|
"role": "developer",
|
|
"event_type": "tool_call",
|
|
});
|
|
let data_json = serde_json::to_string(&data).unwrap();
|
|
let mut h = Sha256::new();
|
|
h.update(prev.as_bytes());
|
|
h.update(data_json.as_bytes());
|
|
let entry_hash = format!("{:x}", h.finalize());
|
|
let signature = hex_encode(&signing.sign(entry_hash.as_bytes()).to_bytes());
|
|
|
|
let mut entry = data;
|
|
entry["timestamp"] = serde_json::json!(format!("2026-06-0{}T00:00:00Z", i + 1));
|
|
entry["prev_hash"] = serde_json::json!(prev);
|
|
entry["entry_hash"] = serde_json::json!(entry_hash);
|
|
entry["signature"] = serde_json::json!(signature);
|
|
entries.push(serde_json::to_string(&entry).unwrap());
|
|
prev = entry_hash;
|
|
}
|
|
let trail = format!("{}\n", entries.join("\n"));
|
|
let policy = r#"{"name":"baseline","version":"1.0.0"}"#.to_string();
|
|
|
|
let files: Vec<(&str, &[u8])> = vec![
|
|
("audit/trail.jsonl", trail.as_bytes()),
|
|
("policies/baseline.resolved.json", policy.as_bytes()),
|
|
];
|
|
|
|
let mut manifest = serde_json::json!({
|
|
"bundle": "evidence-bundle",
|
|
"version": 1,
|
|
"period": { "from": "2026-06-01T00:00:00Z", "to": "2026-06-03T00:00:00Z" },
|
|
"subject": { "agent_id": "agent-1", "project": "fixture" },
|
|
"framework": null,
|
|
"files": files.iter().map(|(p, b)| serde_json::json!({
|
|
"path": p, "sha256": sha256_hex(b)
|
|
})).collect::<Vec<_>>(),
|
|
"chain": { "entries": 3, "anchor_prev_hash": "genesis", "head_hash": prev },
|
|
"signing": {
|
|
"algorithm": "ed25519",
|
|
"public_key": pubkey_hex,
|
|
"signed_digest": "",
|
|
"signature": "",
|
|
}
|
|
});
|
|
let digest = sha256_hex(serde_json::to_string(&manifest).unwrap().as_bytes());
|
|
let signature = hex_encode(&signing.sign(digest.as_bytes()).to_bytes());
|
|
manifest["signing"]["signed_digest"] = serde_json::json!(digest);
|
|
manifest["signing"]["signature"] = serde_json::json!(signature);
|
|
|
|
let mut buf = Vec::new();
|
|
{
|
|
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
|
let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Stored)
|
|
.last_modified_time(zip::DateTime::default());
|
|
let manifest_bytes = serde_json::to_string(&manifest).unwrap().into_bytes();
|
|
let mut all: Vec<(&str, &[u8])> = vec![("manifest.json", &manifest_bytes)];
|
|
all.extend(files.iter().copied());
|
|
all.sort_by_key(|(p, _)| *p);
|
|
for (path, bytes) in all {
|
|
zip.start_file(path, options).unwrap();
|
|
zip.write_all(bytes).unwrap();
|
|
}
|
|
zip.finish().unwrap();
|
|
}
|
|
Fixture {
|
|
bundle: buf,
|
|
pubkey_hex,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn conformant_bundle_verifies_in_both_key_modes() {
|
|
let fx = build_fixture();
|
|
|
|
let report = verify_bundle(&fx.bundle, None);
|
|
assert!(report.valid, "self-attested mode: {:#?}", report.steps);
|
|
assert!(report.key_self_attested);
|
|
|
|
let report = verify_bundle(&fx.bundle, Some(&fx.pubkey_hex));
|
|
assert!(report.valid, "out-of-band mode: {:#?}", report.steps);
|
|
assert!(!report.key_self_attested);
|
|
assert!(report.steps.iter().all(|s| s.status == StepStatus::Pass));
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_out_of_band_key_fails_signature_step() {
|
|
let fx = build_fixture();
|
|
let wrong = hex_encode(
|
|
SigningKey::from_bytes(&[9u8; 32])
|
|
.verifying_key()
|
|
.as_bytes(),
|
|
);
|
|
let report = verify_bundle(&fx.bundle, Some(&wrong));
|
|
assert!(!report.valid);
|
|
}
|
|
|
|
/// AC 1 core: flip ONE byte at every offset class of the archive and
|
|
/// demand detection. ZIP local-header hash bytes, payload bytes, manifest
|
|
/// bytes — anything that changes the verified surfaces must fail; flips
|
|
/// that only touch ZIP padding/metadata may still pass content checks but
|
|
/// must never corrupt a PASS into wrong content.
|
|
#[test]
|
|
fn single_byte_flips_in_payload_are_detected() {
|
|
let fx = build_fixture();
|
|
|
|
// Locate the payload regions: every stored (uncompressed) file's bytes
|
|
// appear verbatim in the archive — flip a byte inside each.
|
|
let needles: [&[u8]; 4] = [
|
|
b"ctx_search", // audit entry content
|
|
b"genesis", // chain anchor in trail
|
|
b"baseline", // policy payload
|
|
b"evidence-bundle", // manifest content
|
|
];
|
|
let mut tested = 0;
|
|
for needle in needles {
|
|
if let Some(pos) = find(&fx.bundle, needle) {
|
|
let mut mutated = fx.bundle.clone();
|
|
mutated[pos] ^= 0x01;
|
|
let report = verify_bundle(&mutated, Some(&fx.pubkey_hex));
|
|
assert!(
|
|
!report.valid,
|
|
"1-byte flip in {:?} region was NOT detected",
|
|
String::from_utf8_lossy(needle)
|
|
);
|
|
tested += 1;
|
|
}
|
|
}
|
|
assert_eq!(tested, 4, "all four payload regions must be present");
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_chain_is_detected() {
|
|
let fx = build_fixture();
|
|
// Rebuild the archive with the last audit line dropped but the original
|
|
// manifest kept — count/head mismatch must fail.
|
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&fx.bundle)).unwrap();
|
|
let mut out = Vec::new();
|
|
{
|
|
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
|
|
let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Stored)
|
|
.last_modified_time(zip::DateTime::default());
|
|
for i in 0..archive.len() {
|
|
let mut entry = archive.by_index(i).unwrap();
|
|
let mut buf = Vec::new();
|
|
std::io::Read::read_to_end(&mut entry, &mut buf).unwrap();
|
|
let name = entry.name().to_string();
|
|
if name == "audit/trail.jsonl" {
|
|
let text = String::from_utf8(buf).unwrap();
|
|
let mut lines: Vec<&str> = text.lines().collect();
|
|
lines.pop();
|
|
buf = format!("{}\n", lines.join("\n")).into_bytes();
|
|
}
|
|
zip.start_file(&name, options).unwrap();
|
|
zip.write_all(&buf).unwrap();
|
|
}
|
|
zip.finish().unwrap();
|
|
}
|
|
let report = verify_bundle(&out, Some(&fx.pubkey_hex));
|
|
assert!(!report.valid, "dropped tail entry must be detected");
|
|
}
|
|
|
|
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
haystack.windows(needle.len()).position(|w| w == needle)
|
|
}
|