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 = 0 u32 ;
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:
Insert leaf at position N
Find its sibling (position N±1)
Hash them together to form parent: parent = keccak256(left || right)
Repeat until root is reached
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:
Recompute Merkle root from all leaf entries
Compare to stored root in latest entry
If they match: log is intact ✅
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
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
If sued:
Export CSV with all decisions
Generate Merkle proofs for specific entries
Prove logs haven’t been altered since collection date
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