Skip to content

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

Choose a tag to compare

@andreysparish andreysparish released this 11 Jul 08:23
a5e5b86

[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 then verify() reports
    head_anchored=False for 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 remain ok=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_key reads 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_log now 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/v1 was a moving branch).
  • Version metadata synced: __version__ said 0.2.0 while
    pyproject.toml said 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_hash and
    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, and NULL encodes distinctly from the empty string.
  • AuditLog.verify() — walks the chain oldest-first and returns a
    VerifyResult naming 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 every anchor_every rows (default: every write)
    and flushed on close(). verify() compares chain head to anchor.
  • VerifyResult.head_anchored — states whether the replacement check
    actually ran. A passing verification with head_anchored=False means
    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 verify CLI. 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_state now frames and validates a
    persisted blob's header (size and version bounds) in Python before its
    bytes reach llama.cpp's C state_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 (see SECURITY.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. See RELEASING.md.

Breaking

  • A missing SQLCipher build no longer degrades silently to plaintext.
    Previously, if sqlcipher3 (the optional [encryption] extra) was not
    installed, the audit log accepted the encryption key, ignored it, and
    wrote an unencrypted database. It now raises AuditIntegrityError.

    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
    validate PRAGMA key when 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 in SECURITY.md,
    which also names the residual weaknesses (process-supplied timestamps,
    the anchor_every window, no independent audit).
  • Tests for this work attack the database file directly with sqlite3,
    bypassing AuditLog entirely — 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 guards load_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.md is 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.