This release closes the gap between the claim and the code — and hardens the surrounding supply chain: a reproducible SBOM and a signed GitHub Release, coverage-guided fuzzing of the untrusted-input path, and a documented governance model and security assurance case.
Latest[0.5.0] — 2026-07-11
The audit log becomes genuinely tamper-evident. Prior versions
described it that way, but provided only encryption at rest and an
append-only API surface: anyone holding the key could open the database
and rewrite or delete rows leaving no trace. Encryption is
confidentiality, not integrity. This release closes the gap between the
claim and the code — and hardens the surrounding supply chain: a
reproducible SBOM and a signed GitHub Release, coverage-guided fuzzing of
the untrusted-input path, and a documented governance model and security
assurance case.
Security (0.4.1 hardening, from the 2026-07 internal audit)
- Per-database head anchors. The keychain anchor entry is now scoped
to the log's resolved path (anchor_scope). Previously the anchor was
machine-global: two audit logs on one host overwrote each other's
anchor, making an honest log verify as "replaced" — and burying a real
alarm in false ones. Existing logs re-anchor under the scoped name on
their first post-upgrade write; until thenverify()reports
head_anchored=Falsefor them. - Anchor write failures are counted, not swallowed.
record()now
tracks failed keychain writes (AuditLog.anchor_failures) and logs a
one-time warning, instead of silently dropping the wholesale-replacement
guarantee mid-run. verify()distinguishes an unanchored tail from a replacement. A
stale anchor that names a row inside the chain is now reported as
anchor_lag=N("chain extends N rows beyond the anchor" — a crash
between commit and anchoring, or appends without keychain access),
while an anchor naming no row in the chain is reported as a
replacement/rollback. Both remainok=False; the diagnosis differs.- Error messages in audit rows are clipped (200 chars). Exception
text from other libraries can embed URLs with tokens or payload
fragments, which does not belong in a metadata-only log. - Audit DB file permissions tightened to owner-only (best-effort
0600), which matters most for the explicitly-permitted plaintext path. - First-run key race closed.
load_or_create_keyreads back the
stored key after writing, so two processes racing through first run
converge on one key instead of encrypting with a loser's key. set_audit_lognow takes the singleton lock (it was declared and
unused).- llama-server stderr no longer uses an unread
PIPE(a child that
logs > 64 KiB would block on write and hang); stderr goes to a temp
file whose tail is included in startup-failure errors. - All GitHub Actions pinned to commit SHAs (tags are mutable refs;
pypa/gh-action-pypi-publish@release/v1was a moving branch). - Version metadata synced:
__version__said 0.2.0 while
pyproject.tomlsaid 0.4.0; both now 0.4.1.
Deferred by decision: local llama-server child runs without --api-key
(any same-host process can reach it). Accepted for the current testing
phase; the planned split of Level 3 into a separate distribution changes
the HTTP exposure model and will revisit this.
Added
- Hash-chained audit records. Every row now carries
prev_hashand
row_hash = SHA-256(prev_hash || canonical(fields)). Altering,
deleting, or reordering any row breaks the chain. The canonical
encoding is length-prefixed, so no field value can forge a record
boundary, andNULLencodes distinctly from the empty string. AuditLog.verify()— walks the chain oldest-first and returns a
VerifyResultnaming the first row whose recorded hash or predecessor
link fails.- Out-of-band head anchor. A chain alone cannot detect wholesale
replacement — an attacker with the key can rebuild a consistent chain
from scratch. The chain head is therefore also stored in the OS
keychain, refreshed everyanchor_everyrows (default: every write)
and flushed onclose().verify()compares chain head to anchor. VerifyResult.head_anchored— states whether the replacement check
actually ran. A passing verification withhead_anchored=Falsemeans
the chain is internally consistent but replacement would not have been
caught (for example, on a host with no keychain). The flag exists so a
passing result is never read as stronger than it is.AuditIntegrityError— raised when the store cannot be opened in a
trustworthy state, distinct from a verification result.palimpsests audit verifyCLI. Runs verification from the command
line with distinct exit codes (clean / tampered / unanchored /
operational error), so integrity can be checked in a script or a
scheduled job, not only from the API.- KV-state blob validation.
load_statenow frames and validates a
persisted blob's header (size and version bounds) in Python before its
bytes reach llama.cpp's Cstate_set, so a malformed or truncated blob
is rejected rather than parsed in C. The C parser it guards remains out
of scope until a disk-backed store ships — at which point persisted
blobs must also be MAC'd (seeSECURITY.md). - CycloneDX SBOM and a signed GitHub Release. The release workflow now
generates a reproducible CycloneDX SBOM of the base install (from a
clean environment, so build tooling never enters the bill of materials),
and publishes a GitHub Release carrying the wheel, sdist, and SBOM as
assets — which also makes this changelog's per-version release links
resolve. SeeRELEASING.md.
Breaking
-
A missing SQLCipher build no longer degrades silently to plaintext.
Previously, ifsqlcipher3(the optional[encryption]extra) was not
installed, the audit log accepted the encryption key, ignored it, and
wrote an unencrypted database. It now raisesAuditIntegrityError.To keep the previous behavior, choose it explicitly:
pip install 'palimpsests[encryption]' # preferred: actually encrypt # or, accepting a plaintext audit log: export PALIMPSESTS_ALLOW_UNENCRYPTED_AUDIT=1
In the API, pass
AuditLog(..., allow_unencrypted=True). A plaintext
log is still hash-chained: tampering remains evident, only
confidentiality is given up.
Fixed
- A wrong encryption key now fails at open. SQLCipher does not
validatePRAGMA keywhen it is set, so a wrong key previously sailed
past the constructor — and could initialize a new encrypted database
over what looked like an unreadable one. A sanity read now forces the
failure immediately.
Notes
- The honest boundary is documented, not implied. An attacker holding
the encryption key and write access to the keychain can forge the
chain and its anchor together. Detecting that requires committing the
chain head outside the host's trust boundary — a remote append-only
log, a notary, a transparency log. Palimpsests does not do this and
does not claim it. See the audit-log threat model inSECURITY.md,
which also names the residual weaknesses (process-supplied timestamps,
theanchor_everywindow, no independent audit). - Tests for this work attack the database file directly with
sqlite3,
bypassingAuditLogentirely — an attacker does not politely go
through a class whose API offers no mutation. - Coverage-guided fuzzing. An Atheris (libFuzzer) harness now fuzzes
the KV-state validator that guardsload_state— a short deterministic
regression on every change and a budget nightly
(.github/workflows/fuzz.yml). The C parser the validator guards is
deliberately out of the harness's scope. - Governance and an assurance case are documented.
GOVERNANCE.md
states how decisions are made and where release authority sits;
docs/ASSURANCE-CASE.mdis a Claims–Arguments–Evidence argument for the
security and record-keeping properties, with each claim's residual named
and a table of the conditions that would defeat it.