feat(observability): append-only hash chain for LLM log records - #232
Conversation
Every llm.jsonl line already records what was asked and answered. It does not record enough to answer the question that matters after an incident: which endpoint did this session actually talk to, and has anything in the file been changed since it was written? This adds both, without changing what is collected elsewhere. * LLMLogRecord gains optional endpoint_host, endpoint_class, request_nonce, response_sha256, tool_calls_sha256, prev_hash and entry_hash. All default to None, so existing construction sites and the JSONL shape of every other sink (mcp.jsonl, system.jsonl, server-*.jsonl) are untouched. * entry_hash covers the persisted entry plus the previous entry's hash, so the file is a chain: editing, deleting or reordering a line is detectable. * The digest is computed at write time in bus.py, under a lock that spans recover + hash + append, and the chain recovers the previous tail by scanning backwards when a process reopens an existing file. A crash-truncated final line is skipped rather than allowed to swallow the next entry. * scripts/verify_transparency_log.py walks a file and reports the line number and reason for the first break; exit 0 clean, 1 broken, 2 usage/IO. DEEPCODE_TRANSPARENCY_LOG=0 disables the chain and restores the previous line shape. Limitation, stated rather than hidden: the lock serialises threads within one process, not separate processes. Two processes appending to the same llm.jsonl can fork the chain, and the verifier will report a fork that is not an attack. A deployment with several writers against one log file should disable it or give each writer its own task directory. Tests: tests/test_transparency_log.py (20) - chain and verify, tamper / delete / reorder detection naming the line, corrupt-tail survival, chain recovery after a cache reset and in a genuinely fresh interpreter, a 40-writer thread test, legacy make() compatibility, and that MCP records stay unchained.
|
@Zongwei9888 requesting security review per CONTRIBUTING.md (the same fork-side notes as #229/#230 apply: the Two things I would like a decision on:
|
Adds optional transparency-log fields (endpoint host/class, digests, prev/entry hash) to LLMLogRecord, chains llm.jsonl entries at write time with tail recovery, and ships scripts/verify_transparency_log.py. Repair: the chain is opt-in via DEEPCODE_TRANSPARENCY_LOG=1 because the append lock does not cover separate processes writing the same file. Contributed by raymondginger2018-sudo.
|
Merged into On the decision you asked for: the chain is now opt-in ( If you later add per-writer chain files (or file locking in the storage layer), making it default-on would be a natural follow-up. |
Summary
Every
llm.jsonlline already records what was asked and answered. It does not record enough to answer the question that matters after an incident: which endpoint did this session actually talk to, and has anything in the file been changed since it was written?This adds both, without changing what is collected elsewhere.
Changes
core/observability/records.pyLLMLogRecord+canonical_json/sha256_hex/compute_entry_hash/chain_payload/apply_chaincore/observability/bus.pyDEEPCODE_TRANSPARENCY_LOG=0opt-outscripts/verify_transparency_log.pyentry_hashcovers the persisted entry plus the previous entry's hash, so the file is a chain. All seven fields default toNone, so existing construction sites keep working and the shape of every other sink (mcp.jsonl,system.jsonl,server-*.jsonl) is untouched.Security Considerations
Attack surface changed. None added; this is a recording control. It makes silent modification of a local log file detectable, and it records
endpoint_hostnext to each call, which is the fact needed to scope exposure after a credential reaches a suspect relay.Why it is safe to enable by default, and how to turn it off.
DEEPCODE_TRANSPARENCY_LOG=0restores the previous line shape exactly.Decision worth your eye: the chain is on by default. Its value is purely forensic and cannot be recovered retroactively - a user who discovers a suspect relay tomorrow cannot go back and chain yesterday's sessions. If you would rather it default off, that is a one-line change in
transparency_log_enabled()and I will make it.Stated limitation, not hidden. The lock serialises threads within one process, not separate processes. Two processes appending to the same
llm.jsonlcan fork the chain, and the verifier will report a fork that is not an attack. A deployment with several writers against one log file should disable it or give each writer its own task directory. I would rather document this than add file locking that the current storage layer does not have.Verification.
scripts/verify_transparency_log.pywas run end to end: two appended records verify clean (exit 0), and after rewriting one field of the second entry it reportsllm.jsonl:2: entry_hash mismatch - entry content was modified (stored 316ebc7d..., recomputed b01e5b47...)and exits 1.Tests
20 new cases: chain and verify, tamper / delete / reorder detection each naming the line, corrupt-tail survival, chain recovery after a cache reset and in a genuinely fresh interpreter, a 40-writer thread test, legacy
make()compatibility, and an assertion that MCP records stay unchained. The 11 pre-existing observability tests pass unchanged (verified against a pristineupstream/maincheckout in the same worktree).Verified under the repository's own
pyproject.tomlpytest config;ruff checkandruff formatclean.Notes
--no-verifybecause the localpre-commitframework needs/bin/bash, which this Windows checkout lacks.