Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Level 6: Proving what happened

You'll learn how anyone can verify an agent's history, and how tampering is caught.

Run cargo run --example mastery_06_audit

The idea

0 genesispublic keyhash · signature1 policybudget 100hash · signature2 requestedpay $120hash · signature3 allowedby alicehash · signature4 outcomesuccesshash · signatureedited: $120 → $12hash no longer matches contentchain broken from here onprev_hash links every recordto the one before
Each record carries the previous record's hash and is signed. Editing one breaks its hash, and every link after it.

Each record includes the previous record’s hash, and is signed with the agent’s Ed25519 key. With only the public key, anyone can check that no record was changed, removed or reordered. With a checkpoint (seq:hash) published earlier, somewhere the writer can’t change, they can also prove nothing was cut off the end.

The code

//! Lineage Mastery, level 6: proving what happened.
//!
//! Every record is hash-chained to the one before and signed with Ed25519. With the public
//! key alone, anyone can verify a log. With a checkpoint published earlier, they can also
//! prove that nothing was cut off the end. This lesson tampers with a log three ways and
//! shows each one being caught.
//!
//! Run: cargo run --example mastery_06_audit

use std::fs;

use lineage::audit::{self, AuditKey, VerifyOptions};
use lineage::guard::{Guard, Outcome, Policy, ToolRule};
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let dir = std::path::Path::new("mastery-data/06");
    let _ = fs::remove_dir_all(dir);
    let log = dir.join("agent.jsonl");

    let mut guard = Guard::create(&log, AuditKey::load_or_create(dir.join("audit.key"))?, "payer",
                                  Policy::new(1000).allow("pay", ToolRule::cost(1)))?;
    for amount in [120, 75, 310] {
        let id = guard.request("pay", json!({"to": "ACME", "amount": amount}), Some(amount))?.action_id().to_string();
        guard.report(&id, Outcome::success("sent"))?;
    }
    let public_key = guard.status().public_key;
    let checkpoint = guard.head(); // publish this somewhere the writer can't reach
    drop(guard);
    println!("public key  {public_key}\ncheckpoint  {}:{}\n", checkpoint.seq, checkpoint.hash);

    let trusted = VerifyOptions { public_key: Some(public_key.clone()), checkpoint: Some(checkpoint.clone()) };
    let original = fs::read_to_string(&log)?;
    check("original log", &log, &trusted);

    // 1. Change a payment amount.
    fs::write(&log, original.replacen("\"amount\":310", "\"amount\":31", 1))?;
    check("amount 310 edited to 31", &log, &trusted);

    // 2. Delete a record from the middle.
    let lines: Vec<&str> = original.lines().collect();
    let without: Vec<&str> = lines.iter().enumerate().filter(|(i, _)| *i != 5).map(|(_, l)| *l).collect();
    fs::write(&log, without.join("\n") + "\n")?;
    check("record 5 deleted", &log, &trusted);

    // 3. Cut the last records off. The chain is still internally valid...
    fs::write(&log, lines[..lines.len() - 3].join("\n") + "\n")?;
    check("last 3 records cut (key only)", &log, &VerifyOptions { public_key: Some(public_key), checkpoint: None });
    // ...which is exactly why checkpoints exist.
    check("last 3 records cut (with checkpoint)", &log, &trusted);

    fs::write(&log, original)?;
    println!("\nTry it on the command line:\n  lineage audit verify {} --public-key <key> --checkpoint {}:{}", log.display(), checkpoint.seq, checkpoint.hash);
    Ok(())
}

fn check(label: &str, log: &std::path::Path, options: &VerifyOptions) {
    let report = audit::verify_file(log, options);
    match report.failure {
        None => println!("{label:<38} OK      ({} records)", report.records),
        Some(failure) => println!("{label:<38} FAILED  {failure}"),
    }
}

Run it

public key  95a2fd0cbac87cfa44b3e7660c61feb61b1dfb9ee63f5c90cf6f9781fdea3925
checkpoint  10:948cc8797ab8c691734c3ee59434cfaf3f450dc7b7f7b4e3a40955bedc096e36

original log                           OK      (11 records)
amount 310 edited to 31                FAILED  line 9 (seq 8): hash does not match record content
record 5 deleted                       FAILED  line 6 (seq 6): expected seq 5, found 6
last 3 records cut (key only)          OK      (8 records)
last 3 records cut (with checkpoint)   FAILED  log ends at seq 7 before checkpoint (truncated)

What happened

TamperingCaught by
A payment amount changedThe record’s hash no longer matches its content
A record deletedThe sequence numbers and prev_hash links no longer line up
The tail cut offOnly the checkpoint. A shorter chain is still internally valid

That last row is why you publish checkpoints, for example to a ticket, another system, or a daily email. In production the guard server gives you one from GET /v1/agents/:id/verify.

Try this

Verify the log from the command line, as an auditor would:

lineage audit verify mastery-data/06/agent.jsonl \
  --public-key "$(lineage audit pubkey mastery-data/06/audit.key)"

Then write your own verifier from the Log format spec. It’s about forty lines of Python.

Next: The guard server →