Skip to main content

Audit Trail

Fishnet maintains a cryptographically verifiable audit log of every decision it makes. Logs are stored in a Merkle tree, making tampering detectable.

Why Merkle Trees?

Traditional logs can be silently modified:
  • Attacker deletes a row from SQLite → No trace
  • Attacker edits a decision from “denied” to “approved” → Undetectable
Merkle trees make tampering obvious:
  • Every log entry is hashed into a leaf node
  • Leaf nodes are combined into parent nodes up to a root hash
  • Changing any entry changes the root hash
  • You can prove a log entry existed without revealing the entire log
Fishnet uses the same Merkle tree construction as Bitcoin and Ethereum — proven and battle-tested.

How It Works

1. Log Entry Structure

From audit/mod.rs:34-47:
pub struct AuditEntry {
    pub id: u64,
    pub timestamp: u64,
    pub intent_type: String,       // e.g., "api_call", "onchain_permit"
    pub service: String,           // e.g., "openai", "binance"
    pub action: String,            // e.g., "POST /v1/chat/completions"
    pub decision: String,          // "approved" or "denied"
    pub reason: Option<String>,    // Why it was denied
    pub cost_usd: Option<f64>,     // Cost of this request
    pub policy_version_hash: H256, // Hash of fishnet.toml at this moment
    pub intent_hash: H256,         // Hash of request body
    pub permit_hash: Option<H256>, // For onchain permits
    pub merkle_root: H256,         // Root hash after inserting this entry
}

2. Leaf Hash Computation

From audit/merkle.rs:58-96:
pub fn hash_audit_leaf(payload: &LeafPayload<'_>) -> H256 {
    let mut bytes = Vec::with_capacity(512);
    bytes.extend_from_slice(&payload.id.to_le_bytes());
    bytes.extend_from_slice(&payload.timestamp.to_le_bytes());
    push_string(&mut bytes, payload.intent_type);
    push_string(&mut bytes, payload.service);
    push_string(&mut bytes, payload.action);
    push_string(&mut bytes, payload.decision);

    match payload.reason {
        Some(reason) => {
            bytes.push(1);
            push_string(&mut bytes, reason);
        }
        None => bytes.push(0),
    }

    // Cost stored as micros (i64) to avoid floating-point issues
    match payload.cost_usd {
        Some(cost) if cost.is_finite() && cost >= 0.0 => {
            bytes.push(1);
            let micros = (cost * USD_MICROS_SCALE).round() as i64;
            bytes.extend_from_slice(&micros.to_le_bytes());
        }
        _ => bytes.push(0),
    }

    bytes.extend_from_slice(&payload.policy_version_hash);
    bytes.extend_from_slice(&payload.intent_hash);

    if let Some(hash) = payload.permit_hash {
        bytes.push(1);
        bytes.extend_from_slice(&hash);
    } else {
        bytes.push(0);
    }

    keccak256(&bytes)  // SHA-3 (Keccak-256)
}
Every field is hashed together using Keccak-256 (the same hash function used in Ethereum).

3. Merkle Tree Construction

From audit/merkle.rs:98-144:
pub fn insert_leaf_and_new_parents(
    conn: &Connection,
    entry_id: u64,
    leaf_position: u64,
    leaf_hash: H256,
) -> rusqlite::Result<H256> {
    insert_node(conn, entry_id, 0, leaf_position, true, &leaf_hash)?;

    let mut current_hash = leaf_hash;
    let mut current_level = 0u32;
    let mut current_position = leaf_position;
    let target_height = tree_height_for_leaf_count(leaf_position.saturating_add(1));

    while current_level < target_height {
        let sibling_position = if current_position % 2 == 0 {
            current_position + 1
        } else {
            current_position.saturating_sub(1)
        };

        let sibling_hash =
            get_node_hash(conn, current_level, sibling_position)?.unwrap_or(current_hash);

        let (left, right) = if current_position % 2 == 0 {
            (current_hash, sibling_hash)
        } else {
            (sibling_hash, current_hash)
        };

        let parent_hash = hash_pair(&left, &right);
        current_level += 1;
        current_position /= 2;

        insert_node(
            conn,
            entry_id,
            current_level,
            current_position,
            false,
            &parent_hash,
        )?;

        current_hash = parent_hash;
    }

    Ok(current_hash)  // Root hash
}
Process:
  1. Insert leaf at position N
  2. Find its sibling (position N±1)
  3. Hash them together to form parent: parent = keccak256(left || right)
  4. Repeat until root is reached
  5. Store all intermediate nodes in audit_merkle_nodes table

4. Database Schema

CREATE TABLE audit_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp INTEGER NOT NULL,
    intent_type TEXT NOT NULL,
    service TEXT NOT NULL,
    action TEXT NOT NULL,
    decision TEXT NOT NULL,
    reason TEXT,
    cost_usd REAL,
    policy_version_hash BLOB NOT NULL,  -- 32 bytes
    intent_hash BLOB NOT NULL,          -- 32 bytes
    permit_hash BLOB,                   -- 32 bytes (optional)
    merkle_root BLOB NOT NULL           -- 32 bytes
);

CREATE TABLE audit_merkle_nodes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    entry_id INTEGER NOT NULL,
    level INTEGER NOT NULL,
    position INTEGER NOT NULL,
    is_leaf INTEGER NOT NULL,  -- Boolean
    hash BLOB NOT NULL,        -- 32 bytes
    UNIQUE(level, position)
);
From audit/mod.rs:128-166:

Verification

Tampering Detection

From audit/mod.rs:457-481:
pub async fn verify_merkle_consistency(&self) -> Result<bool, AuditError> {
    let computed = recompute_root_from_log(&conn)?;
    let stored = conn
        .query_row(
            "SELECT merkle_root FROM audit_log ORDER BY id DESC LIMIT 1",
            [],
            |row| row.get::<_, Vec<u8>>(0),
        )
        .optional()?;

    let stored = match stored {
        Some(raw) => parse_merkle_blob(&raw, "latest audit_log.merkle_root")?,
        None => ZERO_H256,
    };

    Ok(computed == stored)
}
How it works:
  1. Recompute Merkle root from all leaf entries
  2. Compare to stored root in latest entry
  3. If they match: log is intact ✅
  4. If they differ: log was tampered with ❌
From the test suite in audit/mod.rs:905-923:
#[tokio::test]
async fn tampering_causes_merkle_divergence() {
    let store = AuditStore::open_in_memory().unwrap();
    store.append(sample_entry("approved")).await.unwrap();
    store.append(sample_entry("approved")).await.unwrap();

    {
        let conn = store.conn.lock().unwrap();
        conn.execute(
            "UPDATE audit_log SET decision = 'denied', reason = 'tampered' WHERE id = 1",
            [],
        )
        .unwrap();
    }

    assert!(!store.verify_merkle_consistency().await.unwrap());
}
If verify_merkle_consistency() returns false, someone modified the log. This could indicate:
  • Database corruption
  • Attacker with filesystem access
  • Bug in Fishnet (report it!)

Merkle Proof

You can generate a Merkle proof for any entry to prove it existed without revealing other entries. From audit/merkle.rs:171-203:
pub fn merkle_path_for_leaf(conn: &Connection, leaf_position: u64) -> Result<Vec<H256>> {
    let mut layer = load_leaf_hashes(conn)?;
    if layer.is_empty() {
        return Ok(Vec::new());
    }

    let mut index = leaf_position as usize;
    let mut path = Vec::new();

    while layer.len() > 1 {
        if layer.len() % 2 == 1 {
            let last = *layer.last().unwrap_or(&ZERO_H256);
            layer.push(last);
        }

        let sibling = if index % 2 == 0 { index + 1 } else { index - 1 };
        path.push(layer[sibling]);  // Sibling hash

        let mut next_layer = Vec::with_capacity(layer.len() / 2);
        for pair in layer.chunks_exact(2) {
            next_layer.push(hash_pair(&pair[0], &pair[1]));
        }

        layer = next_layer;
        index /= 2;
    }

    Ok(path)
}
Example: Let’s say you have 8 entries and want to prove entry #3 exists:
Root:                    R
                      /     \
                    P1        P2
                  /   \      /   \
                L0    L1    L2    L3
               / \   / \   / \   / \
              E0 E1 E2 E3 E4 E5 E6 E7

                   (prove this)
Merkle path for E3:
[
  hash(E2),  // Sibling at level 0
  hash(L0),  // Sibling at level 1
  hash(P2),  // Sibling at level 2
]
With this path, you can recompute:
L1 = hash(hash(E2), hash(E3))
P1 = hash(hash(L0), L1)
R  = hash(P1, hash(P2))
If computed R matches stored root → proof valid ✅
Merkle proofs are logarithmic size: For 1 million entries, the proof is only ~20 hashes (640 bytes).

Zero-Knowledge Proofs (Planned)

Fishnet’s roadmap includes ZK-SNARKs to prove compliance without revealing what your agent did. Example use case:
“My AI agent followed all security policies in the last 30 days.”
You can generate a proof that:
  • ✅ All requests respected rate limits
  • ✅ All requests stayed within budgets
  • ✅ No blocked endpoints were called
  • ❌ Without revealing which APIs were called, what prompts were sent, or how much was spent
This is useful for:
  • Audits: Prove compliance to regulators without exposing trade secrets
  • Insurance: Prove you followed best practices without revealing strategies
  • Partnerships: Show you’re a responsible API consumer without leaking usage patterns
ZK proofs are not yet implemented but the Merkle tree structure is designed to support them. Follow development at @FishnetDev.

Policy Version Hash

Every entry includes a hash of fishnet.toml at the moment of the decision. From audit/mod.rs:503-513:
pub fn policy_version_hash(
    _path: &Path,
    config: &fishnet_types::config::FishnetConfig,
) -> H256 {
    let canonical = serde_json::to_value(config).map(canonicalize_json).ok();
    let bytes = canonical
        .as_ref()
        .and_then(|value| serde_json::to_vec(value).ok())
        .unwrap_or_else(|| b"{}".to_vec());
    keccak256(&bytes)
}
Why?
  • If you change a policy and a decision changes, you can prove it was due to the policy update, not tampering
  • You can diff policies between entries to see what changed
From the test suite in audit/mod.rs:945-983:
#[test]
fn policy_hash_is_stable_across_hashmap_insertion_order() {
    let mut a = FishnetConfig::default();
    a.custom.insert("alpha", ...);
    a.custom.insert("beta", ...);

    let mut b = FishnetConfig::default();
    b.custom.insert("beta", ...);  // Reverse order
    b.custom.insert("alpha", ...);

    let hash_a = policy_version_hash(Path::new("unused"), &a);
    let hash_b = policy_version_hash(Path::new("unused"), &b);
    assert_eq!(hash_a, hash_b);  // Same hash despite different insertion order
}
JSON is canonicalized (keys sorted) before hashing to ensure deterministic output.

Intent Hash

The intent hash is a hash of the request your agent made. From audit/mod.rs:515-530:
pub fn hash_api_intent(
    method: &str,
    service: &str,
    action: &str,
    query: Option<&str>,
    body: &[u8],
) -> H256 {
    let mut bytes = Vec::with_capacity(body.len() + 256);
    push_string(&mut bytes, method);
    push_string(&mut bytes, service);
    push_string(&mut bytes, action);
    push_string(&mut bytes, query.unwrap_or(""));
    bytes.extend_from_slice(&(body.len() as u64).to_le_bytes());
    bytes.extend_from_slice(body);
    keccak256(&bytes)
}
Includes:
  • HTTP method (e.g., POST)
  • Service (e.g., openai)
  • Action (e.g., /v1/chat/completions)
  • Query string (e.g., ?model=gpt-4)
  • Request body (entire JSON payload)
This proves exactly what the agent tried to do.

Export

You can export the entire log as CSV:
curl http://localhost:8473/api/audit/export \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -o audit-log.csv
From audit/mod.rs:576-643:
pub async fn export_audit_csv(...) -> impl IntoResponse {
    let mut csv = String::from(
        "id,timestamp,intent_type,service,action,decision,reason,cost_usd,policy_version_hash,intent_hash,permit_hash,merkle_root\n",
    );

    for entry in entries {
        csv.push_str(&format!(
            "{},{},{},{},{},{},{},{},{},{},{},{}\n",
            entry.id,
            entry.timestamp,
            csv_cell(&entry.intent_type),
            csv_cell(&entry.service),
            csv_cell(&entry.action),
            csv_cell(&entry.decision),
            csv_cell(&reason),
            csv_cell(&cost),
            csv_cell(&h256_to_hex(&entry.policy_version_hash)),
            csv_cell(&h256_to_hex(&entry.intent_hash)),
            csv_cell(&permit),
            csv_cell(&h256_to_hex(&entry.merkle_root)),
        ));
    }

    ([("content-type", "text/csv; charset=utf-8")], csv).into_response()
}
CSV export includes formula injection protection: Cells starting with =, +, -, @ are prefixed with ' to prevent Excel from executing them.

Storage Location

The audit database is stored at:
  • Linux: /var/lib/fishnet/audit.db
  • macOS: /Library/Application Support/Fishnet/audit.db
  • Custom: Set FISHNET_DATA_DIR environment variable

Performance

  • Log insertion: < 10ms (async, doesn’t block proxy)
  • Merkle root computation: < 5ms for up to 100K entries
  • Verification: < 50ms to recompute root from entire log
  • Merkle proof generation: < 1ms (log(N) complexity)

Use Cases

Regulators can:
  • Download your audit log
  • Verify Merkle root matches latest entry
  • Prove you didn’t delete blocked transactions
  • Check policy versions to ensure compliance over time
If your agent goes rogue:
  • Review audit log to see exactly what it tried to do
  • Filter by decision = 'denied' to see what was blocked
  • Check cost_usd to see where money was spent
  • Verify no one tampered with logs post-incident
  • Query by service to see request volumes
  • Check timestamp to find bursts
  • Correlate with rate limit denials

Example: Detecting Tampering

# Run verification check
curl http://localhost:8473/api/audit/verify \
  -H "Authorization: Bearer $SESSION_TOKEN"

# Response:
{
  "consistent": true,
  "latest_root": "0x1234...",
  "computed_root": "0x1234...",
  "entry_count": 1523
}
If consistent: false, someone modified the log.

Next Steps

Architecture

Understand Fishnet’s proxy model

Credential Vault

Learn how credentials are encrypted

Build docs developers (and LLMs) love