From 6612517076e67a60657bdf50a00523d5c225e555 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Sat, 18 Jul 2026 19:29:44 +0200 Subject: [PATCH] feat(security): add vault-anchor downgrade resistance for transcript integrity Closes #6449 A file-write-only attacker could strip every chain field from a subagent transcript or session log JSONL file, or delete a durable execution's integrity row entirely, making tampered content indistinguishable from genuine pre-feature legacy content that is auto-trusted by design (#6360's stated residual). Adds a per-file vault anchor for the JSONL surfaces: a small {epoch, count, head, written_at} attestation mirrored to the age vault on finalize and checked on read. Since the vault cannot be selectively edited without the age key, a whole-file strip now leaves a detectable mismatch between the file and its anchor. A bounded reconcile-and-cap sweep reaps orphaned anchors and caps vault growth via LRU eviction keyed on the anchor's own embedded write timestamp, not filesystem mtime, so eviction order cannot be manipulated by a file-write attacker. For durable executions, replaces the "missing integrity row is legacy" trust assumption with an operator-gated vault seal: once sealed, an absent integrity row on a keyed, drained execution is unconditional tamper. The seal boundary is entirely vault-resolved so no attacker-writable database column participates in the tamper decision. Config gains `[integrity]` (anchor mode, session-anchor cap); new CLI `durable seal-integrity` and `sessions verify`; wired into doctor, --init, --migrate-config, and the TUI command palette. Accepted residuals (session-prefix rollback, grandfather opt-out, reconcile-window recreation) are documented rather than silently left implicit. --- CHANGELOG.md | 59 ++ README.md | 2 +- .../src/session_sink.rs | 14 + crates/zeph-common/src/anchor.rs | 342 ++++++++ crates/zeph-common/src/hash_chain.rs | 57 +- crates/zeph-common/src/lib.rs | 1 + crates/zeph-config/src/integrity.rs | 89 ++ crates/zeph-config/src/lib.rs | 2 + crates/zeph-config/src/migrate/integrity.rs | 72 ++ crates/zeph-config/src/migrate/mod.rs | 26 +- crates/zeph-config/src/migrate/steps.rs | 41 +- crates/zeph-config/src/migrate/tests.rs | 7 +- crates/zeph-config/src/root.rs | 4 + crates/zeph-core/src/agent/builder.rs | 20 +- .../zeph-core/src/agent/durable_bootstrap.rs | 23 +- crates/zeph-core/src/agent/plan.rs | 8 + crates/zeph-core/src/agent/shutdown.rs | 11 + crates/zeph-core/src/agent/state/mod.rs | 14 + crates/zeph-core/src/agent/state/tests.rs | 2 + crates/zeph-core/src/anchor_store.rs | 798 ++++++++++++++++++ crates/zeph-core/src/lib.rs | 1 + crates/zeph-durable/README.md | 13 +- crates/zeph-durable/src/backend/local.rs | 401 ++++++++- crates/zeph-session/src/log.rs | 517 +++++++++++- crates/zeph-subagent/src/agent_loop.rs | 9 + crates/zeph-subagent/src/transcript.rs | 440 +++++++++- crates/zeph-tui/src/app/reducer.rs | 15 + crates/zeph-tui/src/command.rs | 14 +- ...mmand_palette_rounded_border_snapshot.snap | 2 +- specs/081-transcript-integrity/spec.md | 47 +- src/cli.rs | 19 + src/commands/doctor.rs | 78 ++ src/commands/durable.rs | 143 ++++ src/commands/sessions.rs | 65 ++ src/init/mod.rs | 56 ++ src/runner.rs | 43 + 36 files changed, 3346 insertions(+), 109 deletions(-) create mode 100644 crates/zeph-common/src/anchor.rs create mode 100644 crates/zeph-config/src/integrity.rs create mode 100644 crates/zeph-config/src/migrate/integrity.rs create mode 100644 crates/zeph-core/src/anchor_store.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebbe654b..212ee0102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,65 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). surfaces as a raw decode error in the TUI. Scoped out of #6450 and this addendum; tracked as a follow-up in #6459. +- `zeph-common`/`zeph-subagent`/`zeph-session`/`zeph-durable`/`zeph-config`/`zeph-core`/`zeph`: + closed the whole-file/whole-row downgrade gap the transcript-integrity feature (#6360, + PR #6453) left as a tracked follow-up (issue #6449). The hash-chain alone detects in-place + edits and a partial strip of chain metadata, but a fully consistent whole-file strip (delete + every `chain` field so a chained file looks pre-feature-legacy) was undetectable — a + file-write-only attacker could downgrade an already-anchored transcript or session to + trusted-legacy. Fixed with a new **vault anchor**: a small per-file record (`{epoch, count, + head, written_at}`, `zeph_common::anchor::Anchor`) written to the age vault on + finalize/close and checked on read. An age vault entry can only be removed by an attacker + who holds the age private key (decrypt → re-encrypt → rename), so "legacy-looking file, but + an anchor exists for its identity" is an unambiguous tamper signature; an **absent** anchor + is never a tamper signature (it can only mean pre-feature content, `anchor = "none"`, or a + vault outage during finalize) — this is what prevents every pre-#6449 session/transcript from + bricking. + - New `zeph_common::anchor` module: `Anchor`, `AnchorStore` trait (async `get`/`put`/`delete` + plus a sync `get_sync` for the transcript read path, which has call sites outside this + feature's ownership that cannot be made async without a large blast radius), `AnchorSubsystem`, + `anchor_key`/`parse_anchor_key`. Pure, no vault dependency (INV-1) — mirrors `hash_chain`'s + layering. + - `zeph-subagent`/`zeph-session`: `TranscriptWriter::finalize`/`SessionEventLog::finalize` + write the anchor last, after every append is durably flushed. Read paths + (`TranscriptReader::load`/`load_strict`, `SessionEventLog::open`/`read_all`/`read_chunked`) + check the anchor when present: truncation below the anchored count, or a legacy-looking + file with a live anchor, is `TAMPER` (fail-closed, same `--allow-unverified` override split + as #6360); legitimate post-close growth with a matching prefix is `OK`. + - `zeph-durable`: closed the durable-journal downgrade lever too (an attacker with DB write + access could previously `DELETE FROM durable_execution_integrity` on a resumable execution + and resume it unverified). New `LocalBackend::with_integrity_sealed`/`with_grandfather`: once + sealed (a vault-presence marker, `ZEPH_DURABLE_INTEGRITY_SEALED` — never a DB column), an + absent integrity row on a keyed, non-grandfathered execution with ≥1 committed `StepResult` + is unconditional tamper. `zeph durable seal-integrity [--grandfather ]` refuses to + seal while any resumable (`status='running'`) execution has committed results but no + integrity row (drain-before-seal); `--grandfather` records specific execution IDs as a + permanent, vault-recorded opt-out for operators who cannot drain. + - `zeph-core`: new `anchor_store` module — `AgeVaultAnchorStore` (routes blocking age-vault + I/O through `TaskSupervisor::spawn_blocking`, never a raw `tokio::spawn`) and a + reconcile-and-cap sweep (startup + hourly, via `TaskSupervisor`) that reaps orphaned anchors + (file/session gone) and evicts the oldest session anchors once `[integrity] + max_session_anchors` (default 512) is exceeded — ordered by the `written_at` field embedded + *inside* the AEAD-protected anchor value, never filesystem mtime (which a file-write-only + attacker can freely rewrite). An evicted session degrades to chain-only (#6453-level) + protection; it never bricks. + - `zeph-config`: new `[integrity]` section (`anchor: "vault" | "none"`, default `"vault"`; + `max_session_anchors`, default `512`). Migration step 100 (`migrate_integrity_config`) adds + a discoverability advisory block to existing configs. + - CLI: `zeph durable seal-integrity [--grandfather ] [--dry-run]`, `zeph sessions + verify [id]` (chain + anchor check without resuming). `zeph doctor` reports anchor/seal + status; `zeph --init` offers to generate and store `ZEPH_HISTORY_KEY` (the root secret that + activates both #6360 and #6449) directly into the vault. + - Residuals, documented rather than silently accepted: a session anchor is a prefix + commitment as of its last clean close, so an attacker can roll back an unanchored tail (at + most one run's worth of appends since the last close); a grandfathered `execution_id` is a + *permanent* forge-able slot (an operator-accepted opt-out, not free protection); sessions + aged out past `max_session_anchors` fall back to chain-only protection; the reconcile-and-cap + sweep's orphan-reap step removes an anchor whenever its backing file/session directory is + absent — an attacker who first deletes the real file, waits out a sweep window (≤ 1h, or a + restart), then recreates a forged legacy-looking replacement under the same identity gets it + trusted (overlaps the accepted "fabricate a brand-new legacy session" no-backfill residual; + no reap grace-window or tombstone is implemented in this PR). - `zeph-config`/`zeph-core`/`zeph`: added `zeph durable rotate-key`, a windowed rotation command for the durable execution layer's AEAD payload key (`ZEPH_DURABLE_KEY`) — the `XChaCha20Poly1305Cipher::with_previous` rotation window existed but was never operationally diff --git a/README.md b/README.md index 533186222..ca190bdbb 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ Secrets live in an [age-encrypted](https://github.com/FiloSottile/age) vault, ne - **SSRF defense (5 layers)** — HTTPS-only, pre-DNS blocklist, post-DNS IP validation, pinned-address client (blocks DNS-rebinding), and redirect-chain re-validation (max 3 hops). - **ShadowSentinel** — an optional LLM probe evaluates risky tool calls *before* execution, with every verdict written to an audit table. - **Exfiltration guard** — blocks tracking-pixel image links and suspicious URLs in tool output, and suppresses injection-flagged memory writes. -- **Tamper-evident history** — sub-agent transcripts and session event logs are keyed-BLAKE3 hash-chained (vault-keyed `ZEPH_HISTORY_KEY`), so an edited, reordered, or partially-stripped entry is detected and fails closed before it's replayed as trusted prior context; a tampered session can be inspected read-only via `zeph sessions resume --print --allow-unverified`. Durable execution journals use a separate authenticated high-water-mark for the same guarantee. Detects modification and partial tampering; a fully-consistent whole-file strip-to-legacy downgrade is a tracked follow-up, not yet closed. +- **Tamper-evident, downgrade-resistant history** — sub-agent transcripts and session event logs are keyed-BLAKE3 hash-chained (vault-keyed `ZEPH_HISTORY_KEY`), so an edited, reordered, or partially-stripped entry is detected and fails closed before it's replayed as trusted prior context; a tampered session can be inspected read-only via `zeph sessions resume --print --allow-unverified`. A per-file vault anchor (`[integrity] anchor = "vault"`, the default) additionally closes the whole-file-strip downgrade a chain alone can't catch — a file-write-only attacker cannot forge or delete a vault entry. Durable execution journals use a separate authenticated high-water-mark, vault-sealable via `zeph durable seal-integrity` to close the equivalent whole-row-delete lever. `zeph sessions verify` / `zeph doctor` report status. See the [security model](https://bug-ops.github.io/zeph/reference/security.html). diff --git a/crates/zeph-agent-persistence/src/session_sink.rs b/crates/zeph-agent-persistence/src/session_sink.rs index a3dbd109b..ed8c8a788 100644 --- a/crates/zeph-agent-persistence/src/session_sink.rs +++ b/crates/zeph-agent-persistence/src/session_sink.rs @@ -45,6 +45,20 @@ impl SessionSink { &self.session_id } + /// Finalize the underlying session event log (issue #6449): if a vault-anchor store is + /// configured, persists a downgrade-resistance anchor recording this session's chain state + /// as of this clean close. Call this once, at the end of a run (graceful shutdown, session + /// resume-and-close, etc.) — see [`zeph_session::SessionEventLog::finalize`]'s doc for the + /// full contract (best-effort, never a hard failure the caller must propagate). + /// + /// # Errors + /// + /// Returns [`SessionError`] if the configured anchor store's `put` fails. Callers should log + /// and continue rather than fail the whole shutdown sequence over this. + pub async fn finalize(&self) -> Result<(), SessionError> { + self.log.finalize().await + } + /// Record one persisted message as a `SessionEvent`, then update `acp_sessions.last_seq`. /// /// `role == Role::System` (and any future non-exhaustive `Role` variant) is a no-op: diff --git a/crates/zeph-common/src/anchor.rs b/crates/zeph-common/src/anchor.rs new file mode 100644 index 000000000..7255006f9 --- /dev/null +++ b/crates/zeph-common/src/anchor.rs @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Vault-anchor downgrade-resistance primitives (issue #6449). +//! +//! [`crate::hash_chain`] defends against in-place edits, reordering, and a *partial* strip of +//! chain metadata, but explicitly does **not** defend against a **fully consistent whole-file +//! strip** (delete every `chain` field so a chained file looks pre-feature-legacy) — see that +//! module's "Threat model and honest scope" docs. This module closes that gap for +//! already-anchored content: a small per-file record (an [`Anchor`]) is written to the age vault +//! on finalize/close and checked on read. Because an age vault entry can only be removed by an +//! attacker who holds the age private key (decrypt → re-encrypt → rename), and the threat model +//! here is a file-write-only attacker, a whole-file strip can never make the anchor disappear — +//! so "legacy-looking file, but an anchor exists for its identity" is an unambiguous tamper +//! signature. +//! +//! Mirrors [`crate::hash_chain`]'s layering: this module is pure (no vault dependency) so the +//! adapter crates (`zeph-subagent`, `zeph-session`) depend only on the [`AnchorStore`] trait, +//! never on `zeph-vault` directly (INV-1). The binary provides the concrete +//! age-vault-backed implementation and installs it into each adapter's process-global slot at +//! bootstrap, exactly as it already does for [`crate::hash_chain::ChainKeyRing`]. +//! +//! # Absent anchor is never a tamper signature +//! +//! A chained file with **no** anchor is not suspicious: it can only mean the anchor feature was +//! not active when the file was finalized (pre-feature content, `anchor = "none"`, or a vault +//! outage during finalize), never an attacker having deleted a vault entry (that requires the age +//! key). Callers must trust "chained + anchor absent" exactly like plain #6453 behavior — never +//! fail-closed on it, or every session/transcript that predates this feature bricks. + +use std::future::Future; +use std::pin::Pin; + +use crate::hash_chain::ChainHash; + +/// Vault secret name prefix for every anchor key. Also the prefix the reconcile-and-cap sweep +/// filters on when listing vault keys. +pub const ANCHOR_KEY_PREFIX: &str = "ZEPH_HISTORY_ANCHOR_"; + +/// Which subsystem a given anchor belongs to — folded into the vault key so the two subsystems' +/// anchors never collide even if a `file_id` happened to coincide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AnchorSubsystem { + /// `zeph-subagent` transcript (`.jsonl`). + SubagentTranscript, + /// `zeph-session` event log (`events.jsonl`). + SessionLog, +} + +impl AnchorSubsystem { + /// The vault-key segment for this subsystem (`SUBAGENT` / `SESSION`). + #[must_use] + pub const fn key_segment(self) -> &'static str { + match self { + Self::SubagentTranscript => "SUBAGENT", + Self::SessionLog => "SESSION", + } + } +} + +/// A per-file downgrade-resistance record, stored as an age-vault secret keyed by +/// [`anchor_key`]. +/// +/// Authenticity comes entirely from the vault's own AEAD encryption — the anchor carries no MAC +/// of its own, since an attacker who cannot decrypt the vault cannot forge or delete an entry +/// either way. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Anchor { + /// Format version, for forward compatibility. + pub version: u8, + /// The finalizing key epoch (cross-check + operator diagnostics only — not required to + /// match on read, since a legitimately re-keyed file may resolve under a different epoch). + pub epoch: u32, + /// Total on-disk entry count at the time this anchor was written. + pub count: u64, + /// The verified chain head at exactly `count` entries, hex-encoded. + pub head_hex: String, + /// Wall-clock milliseconds at write time, embedded **inside** this AEAD-protected value so + /// it is unforgeable by a file-write-only attacker (unlike filesystem mtime, which such an + /// attacker can freely rewrite via `utimensat`). Used by the session-anchor reconcile-and-cap + /// sweep to select the true oldest anchor for eviction (issue #6449 rev2 critic S3) — eviction + /// ordering must never depend on an attacker-controlled signal. + pub written_at: u64, +} + +/// Current [`Anchor::version`]. +pub const ANCHOR_VERSION: u8 = 1; + +impl Anchor { + /// Construct a new anchor for a file finalized with `epoch`/`count`/`head`, stamping + /// [`Self::written_at`] with the current wall-clock time. + #[must_use] + pub fn new(epoch: u32, count: u64, head: ChainHash) -> Self { + Self { + version: ANCHOR_VERSION, + epoch, + count, + head_hex: head.to_hex(), + written_at: now_unix_millis(), + } + } + + /// Parse [`Self::head_hex`] back into a [`ChainHash`]. + /// + /// # Errors + /// + /// Returns [`AnchorError::Malformed`] if the stored hex is not a valid chain hash — this can + /// only happen if the vault entry was corrupted or hand-edited by the age-key holder, not by + /// a file-write-only attacker. + pub fn head(&self) -> Result { + ChainHash::from_hex(&self.head_hex).map_err(|_| AnchorError::Malformed) + } +} + +fn now_unix_millis() -> u64 { + u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +/// Errors from an [`AnchorStore`] operation. +#[derive(Debug, thiserror::Error)] +pub enum AnchorError { + /// The underlying vault operation failed. + #[error("anchor store I/O failed: {0}")] + Store(String), + /// A stored anchor's `head_hex` was not a valid chain hash. + #[error("stored anchor is malformed")] + Malformed, +} + +/// Storage abstraction for per-file vault anchors (issue #6449). +/// +/// Implementors persist an [`Anchor`] keyed by [`anchor_key`] in a medium a file-write-only +/// attacker cannot forge or delete — in practice, an age vault secret. The adapter crates +/// (`zeph-subagent`, `zeph-session`) depend only on this trait, never on a concrete vault type +/// (INV-1); the binary provides the concrete implementation. +/// +/// Both an async and a sync accessor are provided for [`get`][Self::get]/[`get_sync`][Self::get_sync]: +/// the session-log read path is already fully async, but the transcript read path +/// (`TranscriptReader::load`/`load_strict`) is a plain synchronous function with call sites +/// spread across 3+ crates outside this feature's ownership — making it async would be a large, +/// out-of-scope blast radius (mirrors why `zeph_core::history_integrity` already exposes both +/// [`resolve_key_ring`](../../zeph_core/history_integrity/fn.resolve_key_ring.html) and a `_sync` +/// counterpart for the same reason). `get_sync` is cheap (an in-memory map lookup behind a +/// `std::sync::RwLock`, never a blocking disk read), so calling it from a sync context never +/// risks stalling a tokio worker thread for longer than a brief lock hold. +pub trait AnchorStore: Send + Sync { + /// Fetch the anchor for `(subsystem, file_id)`, if one is configured and present. + /// + /// # Errors + /// + /// Returns [`AnchorError`] on a store-level failure. A simply-absent anchor is `Ok(None)`, + /// never an error — see the module docs' "absent anchor is never a tamper signature" note. + fn get( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Pin, AnchorError>> + Send + '_>>; + + /// Synchronous variant of [`get`][Self::get] — see the trait docs for why this exists + /// alongside the async version. + /// + /// # Errors + /// + /// Same as [`get`][Self::get]. + fn get_sync( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Result, AnchorError>; + + /// Persist `anchor` for `(subsystem, file_id)`, overwriting any prior anchor for the same + /// identity. + /// + /// Implementations performing blocking I/O (age vault re-encryption) **must** route it + /// through `zeph_common::task_supervisor::TaskSupervisor::spawn_blocking`, never a raw + /// `tokio::task::spawn_blocking` or an inline blocking call on the calling task. + /// + /// # Errors + /// + /// Returns [`AnchorError`] on a store-level failure. + fn put( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + anchor: Anchor, + ) -> Pin> + Send + '_>>; + + /// Remove the anchor for `(subsystem, file_id)`, if present. A no-op (not an error) if no + /// anchor exists for this identity. + /// + /// # Errors + /// + /// Returns [`AnchorError`] on a store-level failure. + fn delete( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Pin> + Send + '_>>; +} + +/// Encode `file_id` bytes into the ASCII-safe segment used inside a vault key. +/// +/// `file_id`s in this codebase are always a UUID or a directory/file stem, already restricted to +/// `[A-Za-z0-9._-]` in practice — this is a defense-in-depth guard, not a real-world path: any +/// byte outside that set falls back to a `hex:`-prefixed hex encoding so the resulting key is +/// always safe to embed in a vault key string. +fn encode_file_id(file_id: &[u8]) -> String { + use std::fmt::Write as _; + let safe = file_id + .iter() + .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + if safe { + // Safety of the assumption checked above: every byte is ASCII, so this is valid UTF-8. + String::from_utf8_lossy(file_id).into_owned() + } else { + let mut out = String::with_capacity(4 + file_id.len() * 2); + out.push_str("hex:"); + for b in file_id { + let _ = write!(out, "{b:02x}"); + } + out + } +} + +/// Decode an [`encode_file_id`]-produced segment back into raw bytes. +fn decode_file_id(segment: &str) -> Option> { + if let Some(hex) = segment.strip_prefix("hex:") { + if hex.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(hex.len() / 2); + let bytes = hex.as_bytes(); + for chunk in bytes.chunks(2) { + let hi = (chunk[0] as char).to_digit(16)?; + let lo = (chunk[1] as char).to_digit(16)?; + out.push(u8::try_from(hi * 16 + lo).ok()?); + } + Some(out) + } else { + Some(segment.as_bytes().to_vec()) + } +} + +/// Derive the vault key for a given subsystem + file identity. +/// +/// Format: `ZEPH_HISTORY_ANCHOR__` (ASCII-safe-encoded, falling back to a +/// `hex:`-prefixed hex encoding for any byte outside `[A-Za-z0-9._-]`). +#[must_use] +pub fn anchor_key(subsystem: AnchorSubsystem, file_id: &[u8]) -> String { + format!( + "{ANCHOR_KEY_PREFIX}{}_{}", + subsystem.key_segment(), + encode_file_id(file_id) + ) +} + +/// Parse a vault key back into `(subsystem, file_id)`, if it matches [`ANCHOR_KEY_PREFIX`]. +/// +/// Used by the reconcile-and-cap sweep to enumerate anchor keys and map them back to an on-disk +/// identity without needing to track a separate index. +#[must_use] +pub fn parse_anchor_key(key: &str) -> Option<(AnchorSubsystem, Vec)> { + let rest = key.strip_prefix(ANCHOR_KEY_PREFIX)?; + let (subsystem, file_id_segment) = if let Some(id) = rest.strip_prefix("SUBAGENT_") { + (AnchorSubsystem::SubagentTranscript, id) + } else { + let id = rest.strip_prefix("SESSION_")?; + (AnchorSubsystem::SessionLog, id) + }; + decode_file_id(file_id_segment).map(|id| (subsystem, id)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash_chain::{ChainKey, chain_next, genesis}; + + fn sample_head() -> ChainHash { + let key = ChainKey::new([1u8; 32]); + let base = genesis(&key, "d", b"f", 0); + chain_next(&key, &base, b"content") + } + + #[test] + fn anchor_key_round_trips_for_safe_ids() { + let key = anchor_key(AnchorSubsystem::SubagentTranscript, b"abc-123.task"); + assert_eq!(key, "ZEPH_HISTORY_ANCHOR_SUBAGENT_abc-123.task"); + let (subsystem, id) = parse_anchor_key(&key).unwrap(); + assert_eq!(subsystem, AnchorSubsystem::SubagentTranscript); + assert_eq!(id, b"abc-123.task"); + } + + #[test] + fn anchor_key_round_trips_for_unsafe_bytes() { + let file_id = vec![0xffu8, 0x00, b'/']; + let key = anchor_key(AnchorSubsystem::SessionLog, &file_id); + assert!(key.starts_with("ZEPH_HISTORY_ANCHOR_SESSION_hex:")); + let (subsystem, id) = parse_anchor_key(&key).unwrap(); + assert_eq!(subsystem, AnchorSubsystem::SessionLog); + assert_eq!(id, file_id); + } + + #[test] + fn parse_anchor_key_rejects_unrelated_keys() { + assert!(parse_anchor_key("ZEPH_OPENAI_API_KEY").is_none()); + assert!(parse_anchor_key("ZEPH_HISTORY_ANCHOR_BOGUS_x").is_none()); + } + + #[test] + fn anchor_new_stamps_written_at_and_round_trips_head() { + let head = sample_head(); + let anchor = Anchor::new(3, 42, head); + assert_eq!(anchor.version, ANCHOR_VERSION); + assert_eq!(anchor.epoch, 3); + assert_eq!(anchor.count, 42); + assert!(anchor.written_at > 0); + assert_eq!(anchor.head().unwrap(), head); + } + + #[test] + fn anchor_serializes_to_json_and_back() { + let anchor = Anchor::new(0, 7, sample_head()); + let json = serde_json::to_string(&anchor).unwrap(); + let round_tripped: Anchor = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped.count, 7); + assert_eq!(round_tripped.head_hex, anchor.head_hex); + assert_eq!(round_tripped.written_at, anchor.written_at); + } + + #[test] + fn anchor_head_rejects_malformed_hex() { + let mut anchor = Anchor::new(0, 1, sample_head()); + anchor.head_hex = "not-hex".to_owned(); + assert!(matches!(anchor.head(), Err(AnchorError::Malformed))); + } +} diff --git a/crates/zeph-common/src/hash_chain.rs b/crates/zeph-common/src/hash_chain.rs index 4f756ba4e..cb8a94629 100644 --- a/crates/zeph-common/src/hash_chain.rs +++ b/crates/zeph-common/src/hash_chain.rs @@ -467,18 +467,47 @@ pub fn verify_chained_prefix( file_identity: &[u8], entries: &[(Vec, ChainHash)], ) -> Result<(ChainHash, KeyResolution), ChainError> { + let (head, _checkpoint, resolution) = + verify_chained_prefix_with_checkpoint(ring, domain, file_identity, entries, u64::MAX)?; + Ok((head, resolution)) +} + +/// Like [`verify_chained_prefix`], but additionally captures the chain head immediately after +/// entry `checkpoint_index` (0-based within `entries`) is verified — used by the vault-anchor +/// downgrade-resistance mechanism (issue #6449) to compare a stored anchor's head against the +/// file's head at the anchor's recorded count, without re-deriving the chain a second time. +/// +/// Returns `(final_head, checkpoint_head, resolution)`. `checkpoint_head` is `None` if +/// `checkpoint_index >= entries.len()` (out of range — including the common case of passing +/// `u64::MAX` from [`verify_chained_prefix`] to opt out of capturing a checkpoint). +/// +/// # Errors +/// +/// Same as [`verify_chained_prefix`]. +pub fn verify_chained_prefix_with_checkpoint( + ring: &ChainKeyRing, + domain: &str, + file_identity: &[u8], + entries: &[(Vec, ChainHash)], + checkpoint_index: u64, +) -> Result<(ChainHash, Option, KeyResolution), ChainError> { if entries.is_empty() { // Nothing to verify: an empty chained region has no key to resolve. Callers should not // invoke this with an empty slice; treat it as trivially verified at the current epoch. return Ok(( genesis(&ring.current_key, domain, file_identity, ring.current_epoch), + None, KeyResolution::Current, )); } let mut streaming = ChainStreamVerifier::new(ring, domain, file_identity.to_vec()); - for (content, stored) in entries { + let mut checkpoint_head = None; + for (i, (content, stored)) in entries.iter().enumerate() { streaming.verify_next(content, stored)?; + if i as u64 == checkpoint_index { + checkpoint_head = streaming.head(); + } } // Infallible: the loop above verified at least one entry (entries is non-empty), which // always sets `resolved`/`resolution` on success. @@ -486,7 +515,7 @@ pub fn verify_chained_prefix( .head() .unwrap_or_else(|| genesis(&ring.current_key, domain, file_identity, ring.current_epoch)); let resolution = streaming.resolution().unwrap_or(KeyResolution::Current); - Ok((head, resolution)) + Ok((head, checkpoint_head, resolution)) } #[cfg(test)] @@ -734,6 +763,30 @@ mod tests { assert_eq!(streaming.resolution(), Some(whole_res)); } + #[test] + fn verify_chained_prefix_with_checkpoint_captures_intermediate_head() { + let k = key(13); + let ring = ChainKeyRing::new(0, k); + let base = genesis(&k, "d", b"f", 0); + let h0 = chain_next(&k, &base, b"a"); + let h1 = chain_next(&k, &h0, b"b"); + let h2 = chain_next(&k, &h1, b"c"); + let entries = vec![ + (b"a".to_vec(), h0), + (b"b".to_vec(), h1), + (b"c".to_vec(), h2), + ]; + + let (final_head, checkpoint, _res) = + verify_chained_prefix_with_checkpoint(&ring, "d", b"f", &entries, 1).unwrap(); + assert_eq!(final_head, h2); + assert_eq!(checkpoint, Some(h1), "checkpoint at index 1 must be h1"); + + let (_final, out_of_range, _res) = + verify_chained_prefix_with_checkpoint(&ring, "d", b"f", &entries, 99).unwrap(); + assert_eq!(out_of_range, None, "an out-of-range checkpoint is None"); + } + #[test] fn chain_stream_verifier_detects_tamper_mid_stream() { let k = key(12); diff --git a/crates/zeph-common/src/lib.rs b/crates/zeph-common/src/lib.rs index 6f391f834..f5fcb6cf4 100644 --- a/crates/zeph-common/src/lib.rs +++ b/crates/zeph-common/src/lib.rs @@ -9,6 +9,7 @@ //! It has no `zeph-*` dependencies. The optional `treesitter` feature adds tree-sitter //! query constants and helpers. +pub mod anchor; pub mod audit; pub mod clock; pub mod config; diff --git a/crates/zeph-config/src/integrity.rs b/crates/zeph-config/src/integrity.rs new file mode 100644 index 000000000..1e1a5718d --- /dev/null +++ b/crates/zeph-config/src/integrity.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Pure-data configuration for vault-anchor downgrade-resistance (`[integrity]`, issue #6449). +//! +//! Layers on top of the transcript/session hash-chain feature (issue #6360): the chain alone +//! detects in-place edits and a partial strip of chain metadata, but not a fully consistent +//! whole-file strip. `[integrity]` controls whether a per-file vault anchor is written on +//! finalize/close to close that gap. See `zeph_common::anchor` for the mechanism. + +use serde::{Deserialize, Serialize}; + +/// Vault-anchor downgrade-resistance posture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AnchorMode { + /// Write and check a per-file vault anchor (the default, whenever the age vault + a + /// history-integrity key are available). Degrades gracefully — never a hard failure — to + /// chain-only (#6453-level) protection if the vault isn't reachable at bootstrap; see + /// `zeph_core::anchor_store`'s startup warning. + #[default] + Vault, + /// Explicit opt-out: transcripts/sessions stay chain-verified (#6453) but not + /// downgrade-resistant against a whole-file strip. + None, +} + +/// Configuration for vault-anchor downgrade-resistance (`[integrity]`, issue #6449). +/// +/// # Examples +/// +/// ``` +/// use zeph_config::{AnchorMode, IntegrityConfig}; +/// +/// let cfg: IntegrityConfig = toml::from_str("").unwrap(); +/// assert_eq!(cfg.anchor, AnchorMode::Vault); +/// assert_eq!(cfg.max_session_anchors, 512); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct IntegrityConfig { + /// Vault-anchor downgrade-resistance posture. + pub anchor: AnchorMode, + /// Upper bound on the number of session anchors retained in the vault at once. Once + /// exceeded, the reconcile-and-cap sweep evicts the oldest anchors (ordered by the + /// vault-embedded `written_at` field, never filesystem mtime) down to this cap — those + /// sessions degrade to chain-only (#6453-level) protection, never a brick (an evicted + /// session still opens normally). Transcript anchors are independently bounded by + /// `subagent.transcript_max_files` and need no separate cap. Default `512`: typical + /// single-user/small-team deployments never evict. + pub max_session_anchors: usize, +} + +impl Default for IntegrityConfig { + fn default() -> Self { + Self { + anchor: AnchorMode::Vault, + max_session_anchors: 512, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_table_yields_spec_defaults() { + let cfg: IntegrityConfig = toml::from_str("").unwrap(); + assert_eq!(cfg, IntegrityConfig::default()); + } + + #[test] + fn anchor_none_deserializes() { + let cfg: IntegrityConfig = toml::from_str("anchor = \"none\"").unwrap(); + assert_eq!(cfg.anchor, AnchorMode::None); + } + + #[test] + fn max_session_anchors_is_overridable() { + let cfg: IntegrityConfig = toml::from_str("max_session_anchors = 10").unwrap(); + assert_eq!(cfg.max_session_anchors, 10); + assert_eq!( + cfg.anchor, + AnchorMode::Vault, + "unspecified fields keep their default" + ); + } +} diff --git a/crates/zeph-config/src/lib.rs b/crates/zeph-config/src/lib.rs index 00d6209e3..95d40b03b 100644 --- a/crates/zeph-config/src/lib.rs +++ b/crates/zeph-config/src/lib.rs @@ -88,6 +88,7 @@ pub mod experiment; pub mod features; pub mod fidelity; pub mod hooks; +pub mod integrity; pub mod knowledge; pub mod learning; mod loader; @@ -148,6 +149,7 @@ pub use features::{ }; pub use fidelity::FidelityConfig; pub use hooks::{FileChangedConfig, HooksConfig}; +pub use integrity::{AnchorMode, IntegrityConfig}; pub use knowledge::KnowledgeConfig; pub use learning::{DetectorMode, LearningConfig}; pub use logging::{LogRotation, LoggingConfig}; diff --git a/crates/zeph-config/src/migrate/integrity.rs b/crates/zeph-config/src/migrate/integrity.rs new file mode 100644 index 000000000..67ecae2f9 --- /dev/null +++ b/crates/zeph-config/src/migrate/integrity.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Migration for the `[integrity]` section (issue #6449, vault-anchor downgrade-resistance). + +use super::{MigrateError, MigrationResult, section_header_present}; + +/// Add a commented-out `[integrity]` advisory block if absent (issue #6449). +/// +/// All `IntegrityConfig` fields have `#[serde(default)]` so existing configs without this +/// section parse fine (anchor defaults to `"vault"`, closing the whole-file-strip downgrade gap +/// automatically whenever the age vault + a history-integrity key are available). This step is +/// discoverability-only, mirroring the sibling `[deep_link]`/`[knowledge]`/`[plugins.reputation]` +/// advisory blocks. +/// +/// # Errors +/// +/// Infallible in practice; `Result` matches the migration convention. +pub fn migrate_integrity_config(toml_src: &str) -> Result { + if section_header_present(toml_src, "integrity") || toml_src.contains("# [integrity]") { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let comment = "\n# [integrity] — vault-anchor downgrade-resistance for transcript/session \ + history (issue #6449).\n\ + # [integrity]\n\ + # anchor = \"vault\" # \"vault\" (default) | \"none\" (chain-only, #6453-level protection)\n\ + # max_session_anchors = 512 # LRU cap on retained session anchors before the oldest degrade to chain-only\n"; + let output = format!("{toml_src}{comment}"); + + Ok(MigrationResult { + output, + changed_count: 1, + sections_changed: vec!["integrity".to_owned()], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migrate_integrity_config_appends_advisory_block() { + let base = "[agent]\nname = \"zeph\"\n"; + let result = migrate_integrity_config(base).unwrap(); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# [integrity]")); + assert!(result.output.contains("# anchor = \"vault\"")); + assert!(result.output.contains("# max_session_anchors = 512")); + } + + #[test] + fn migrate_integrity_config_idempotent_on_commented_output() { + let base = "[agent]\nname = \"zeph\"\n"; + let first = migrate_integrity_config(base).unwrap(); + let second = migrate_integrity_config(&first.output).unwrap(); + assert_eq!(second.changed_count, 0, "second run must not double-append"); + assert_eq!(second.output, first.output); + } + + #[test] + fn migrate_integrity_config_noop_when_active_section_present() { + let base = "[integrity]\nanchor = \"none\"\n"; + let result = migrate_integrity_config(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); + } +} diff --git a/crates/zeph-config/src/migrate/mod.rs b/crates/zeph-config/src/migrate/mod.rs index 27fef4ab6..571cf9c73 100644 --- a/crates/zeph-config/src/migrate/mod.rs +++ b/crates/zeph-config/src/migrate/mod.rs @@ -13,6 +13,7 @@ use toml_edit::{Array, DocumentMut, Item, Table, Value}; // ── Submodules: migration steps grouped by subsystem (#4874) ───────────────────────────────────── mod features; mod infra; +mod integrity; mod llm; mod mcp; mod memory; @@ -32,6 +33,7 @@ pub use features::{ migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults, }; pub use infra::*; +pub use integrity::migrate_integrity_config; /// Advisory `GonkaGate` migration is crate-internal (registered via the [`MIGRATIONS`] registry). pub(crate) use llm::migrate_gonkagate_to_gonka; pub use llm::*; @@ -610,16 +612,17 @@ use steps::{ MigrateEgressConfig, MigrateEmbedProviderRename, MigrateEvalModelToProvider, MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig, MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig, MigrateGonkagateToGonka, - MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateKnowledgeConfig, - MigrateLlmStreamLimits, MigrateMagicDocsConfig, MigrateMcpElicitationConfig, - MigrateMcpMaxConnectAttempts, MigrateMcpMediaConfig, MigrateMcpRetryAndToolTimeout, - MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported, - MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, - MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge, - MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias, MigrateMemoryStoreConfig, - MigrateMemoryTypeAwareCompose, MigrateMicrocompactConfig, MigrateNliConfig, - MigrateOrchestrationAssetSensitivity, MigrateOrchestrationCommandConfig, - MigrateOrchestrationEnsemble, MigrateOrchestrationIdleTimeout, MigrateOrchestrationPersistence, + MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateIntegrityConfig, + MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig, + MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpMediaConfig, + MigrateMcpRetryAndToolTimeout, MigrateMcpTrustLevels, MigrateMemoryGraph, + MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian, + MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig, + MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval, + MigrateMemoryRetrievalQueryBias, MigrateMemoryStoreConfig, MigrateMemoryTypeAwareCompose, + MigrateMicrocompactConfig, MigrateNliConfig, MigrateOrchestrationAssetSensitivity, + MigrateOrchestrationCommandConfig, MigrateOrchestrationEnsemble, + MigrateOrchestrationIdleTimeout, MigrateOrchestrationPersistence, MigrateOrchestrationWholePlanVerifierTimeout, MigrateOrchestratorProvider, MigrateOtelFilter, MigrateOverflowMaxPerCallOverride, MigratePiiFilterNames, MigratePlannerModelToProvider, MigratePluginsReputationConfig, MigratePolicyProviderAndUtilityWindow, @@ -838,6 +841,9 @@ pub static MIGRATIONS: std::sync::LazyLock> // [durable] table noting that row-HMAC + high-water-mark tamper-evidence // (issue #6360) is unconditional, not a new opt-in toggle Box::new(MigrateDurableHwmAdvisory), + // Step 100 — add [integrity] advisory block for vault-anchor downgrade-resistance + // (issue #6449) + Box::new(MigrateIntegrityConfig), ] }); diff --git a/crates/zeph-config/src/migrate/steps.rs b/crates/zeph-config/src/migrate/steps.rs index ad0d4f556..a781be29f 100644 --- a/crates/zeph-config/src/migrate/steps.rs +++ b/crates/zeph-config/src/migrate/steps.rs @@ -94,20 +94,20 @@ use super::{ migrate_fidelity_timeout_defaults, migrate_five_signal_config, migrate_focus_auto_consolidate_min_window, migrate_forgetting_config, migrate_goals_config, migrate_hooks_permission_denied_config, migrate_hooks_turn_complete_config, - migrate_knowledge_config, migrate_llm_stream_limits, migrate_magic_docs_config, - migrate_mcp_elicitation_config, migrate_mcp_max_connect_attempts, migrate_mcp_media_config, - migrate_mcp_retry_and_tool_timeout, migrate_mcp_trust_levels, migrate_memory_graph_config, - migrate_memory_graph_recall_include_imported, migrate_memory_hebbian_config, - migrate_memory_hebbian_consolidation_config, migrate_memory_hebbian_spread_config, - migrate_memory_persona_config, migrate_memory_reasoning_config, - migrate_memory_reasoning_judge_config, migrate_memory_retrieval_config, - migrate_memory_retrieval_query_bias, migrate_memory_store_config, - migrate_memory_type_aware_compose_config, migrate_microcompact_config, migrate_nli_config, - migrate_orchestration_asset_sensitivity, migrate_orchestration_command_config, - migrate_orchestration_ensemble, migrate_orchestration_idle_timeout, - migrate_orchestration_orchestrator_provider, migrate_orchestration_persistence, - migrate_orchestration_whole_plan_verifier_timeout, migrate_otel_filter, - migrate_overflow_max_per_call_override, migrate_pii_filter_names, + migrate_integrity_config, migrate_knowledge_config, migrate_llm_stream_limits, + migrate_magic_docs_config, migrate_mcp_elicitation_config, migrate_mcp_max_connect_attempts, + migrate_mcp_media_config, migrate_mcp_retry_and_tool_timeout, migrate_mcp_trust_levels, + migrate_memory_graph_config, migrate_memory_graph_recall_include_imported, + migrate_memory_hebbian_config, migrate_memory_hebbian_consolidation_config, + migrate_memory_hebbian_spread_config, migrate_memory_persona_config, + migrate_memory_reasoning_config, migrate_memory_reasoning_judge_config, + migrate_memory_retrieval_config, migrate_memory_retrieval_query_bias, + migrate_memory_store_config, migrate_memory_type_aware_compose_config, + migrate_microcompact_config, migrate_nli_config, migrate_orchestration_asset_sensitivity, + migrate_orchestration_command_config, migrate_orchestration_ensemble, + migrate_orchestration_idle_timeout, migrate_orchestration_orchestrator_provider, + migrate_orchestration_persistence, migrate_orchestration_whole_plan_verifier_timeout, + migrate_otel_filter, migrate_overflow_max_per_call_override, migrate_pii_filter_names, migrate_planner_model_to_provider, migrate_plugins_reputation_config, migrate_policy_provider_and_utility_window, migrate_provider_max_concurrent, migrate_qdrant_api_key, migrate_qdrant_timeout_secs, migrate_quality_config, @@ -1269,3 +1269,16 @@ impl Migration for MigrateDurableHwmAdvisory { migrate_durable_hwm_advisory(toml_src) } } + +/// Step 100 — adds a commented `[integrity]` advisory block for vault-anchor +/// downgrade-resistance (issue #6449). +pub(super) struct MigrateIntegrityConfig; +impl Migration for MigrateIntegrityConfig { + fn name(&self) -> &'static str { + "migrate_integrity_config" + } + + fn apply(&self, toml_src: &str) -> Result { + migrate_integrity_config(toml_src) + } +} diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index f157ef861..089d8d84c 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -9,8 +9,8 @@ use super::*; fn migrations_registry_has_all_steps() { assert_eq!( MIGRATIONS.len(), - 99, - "MIGRATIONS registry must contain all 99 sequential steps" + 100, + "MIGRATIONS registry must contain all 100 sequential steps" ); for m in MIGRATIONS.iter() { assert!( @@ -2074,7 +2074,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() { #[test] fn registry_has_fifty_entries() { - assert_eq!(MIGRATIONS.len(), 99); + assert_eq!(MIGRATIONS.len(), 100); } #[test] @@ -2215,6 +2215,7 @@ fn registry_preserves_order_matches_dispatch() { "migrate_durable_key_rotation", "migrate_plugins_reputation_config", "migrate_durable_hwm_advisory", + "migrate_integrity_config", ]; let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect(); assert_eq!(actual, expected); diff --git a/crates/zeph-config/src/root.rs b/crates/zeph-config/src/root.rs index 7ce95d538..43b66ebf9 100644 --- a/crates/zeph-config/src/root.rs +++ b/crates/zeph-config/src/root.rs @@ -148,6 +148,9 @@ pub struct Config { /// Durable execution layer configuration (`[durable]`). #[serde(default)] pub durable: crate::durable::DurableConfig, + /// Vault-anchor downgrade-resistance configuration (`[integrity]`, issue #6449). + #[serde(default)] + pub integrity: crate::integrity::IntegrityConfig, /// Ultra-compressed telegraphic output mode configuration (`[caveman]`). /// /// Toggle at runtime with `/caveman [on|off]` or via the bundled `caveman` skill. @@ -396,6 +399,7 @@ impl Default for Config { secrets: ResolvedSecrets::default(), worktree: crate::worktree::WorktreeConfig::default(), durable: crate::durable::DurableConfig::default(), + integrity: crate::integrity::IntegrityConfig::default(), caveman: crate::features::CavemanConfig::default(), knowledge: crate::knowledge::KnowledgeConfig::default(), plugins: crate::plugins::PluginsConfig::default(), diff --git a/crates/zeph-core/src/agent/builder.rs b/crates/zeph-core/src/agent/builder.rs index 44df0d0cc..f727b1ffd 100644 --- a/crates/zeph-core/src/agent/builder.rs +++ b/crates/zeph-core/src/agent/builder.rs @@ -2426,10 +2426,12 @@ impl Agent { /// unavailable — see `crate::durable::derive_hwm_key_b64`. `previous_hmac_key` is `Some` /// only while a `zeph durable rotate-key` rotation window is open (#6451). `previous_hwm_key` /// is the HWM-side counterpart, `Some` under the same condition (addendum to #6451). + /// `integrity_seal` (issue #6449) is `(sealed, grandfather)`, resolved from the vault by + /// `crate::commands::durable::load_integrity_seal`. /// - /// `#[allow(clippy::too_many_arguments)]`: bundling the five key-material params into one - /// `DurableKeyMaterial` struct is deliberately deferred (addendum to #6451, M2) — it would - /// re-touch every already-threaded call site (runner → builder → state → bootstrap → plan → + /// `#[allow(clippy::too_many_arguments)]`: bundling the key-material/seal params into one + /// struct is deliberately deferred (addendum to #6451, M2) — it would re-touch every + /// already-threaded call site (runner → builder → state → bootstrap → plan → /// `scheduler_daemon`) a second time for no safety gain, since a mis-wired key `Option` here /// fails closed (`ControlIntegrity`/`HighWaterMarkIntegrity`), never a silent accept. Tracked /// as a follow-up. @@ -2444,6 +2446,7 @@ impl Agent { hwm_key: Option<(u32, [u8; 32])>, previous_hmac_key: Option<[u8; 32]>, previous_hwm_key: Option<(u32, [u8; 32])>, + integrity_seal: (bool, std::collections::HashSet), ) -> Self { self.services.orchestration.durable_config = Some(config); self.services.orchestration.durable_db_url = Some(db_url); @@ -2452,6 +2455,8 @@ impl Agent { self.services.orchestration.durable_hwm_key = hwm_key; self.services.orchestration.durable_previous_hmac_key = previous_hmac_key; self.services.orchestration.durable_previous_hwm_key = previous_hwm_key; + self.services.orchestration.durable_integrity_sealed = integrity_seal.0; + self.services.orchestration.durable_integrity_grandfather = integrity_seal.1; self } @@ -2472,7 +2477,9 @@ impl Agent { /// `db_url`, their executions still would not collide (#5553). /// /// `#[allow(clippy::too_many_arguments)]`: see [`Self::with_durable_orchestration`]'s doc — - /// the `DurableKeyMaterial` bundling is deferred, not skipped. + /// the key-material/seal bundling is deferred, not skipped. + /// `integrity_seal` (issue #6449) is `(sealed, grandfather)`, resolved from the vault by + /// `crate::commands::durable::load_integrity_seal`. #[must_use] #[allow(clippy::too_many_arguments)] pub fn with_durable_agent_turns( @@ -2485,6 +2492,7 @@ impl Agent { hwm_key: Option<(u32, [u8; 32])>, previous_hmac_key: Option<[u8; 32]>, previous_hwm_key: Option<(u32, [u8; 32])>, + integrity_seal: (bool, std::collections::HashSet), ) -> Self { self.services.session.durable_agent_turns_config = Some(config); self.services.session.durable_agent_turns_db_url = Some(db_url); @@ -2494,6 +2502,10 @@ impl Agent { self.services.session.durable_agent_turns_hwm_key = hwm_key; self.services.session.durable_agent_turns_previous_hmac_key = previous_hmac_key; self.services.session.durable_agent_turns_previous_hwm_key = previous_hwm_key; + self.services.session.durable_agent_turns_integrity_sealed = integrity_seal.0; + self.services + .session + .durable_agent_turns_integrity_grandfather = integrity_seal.1; self } diff --git a/crates/zeph-core/src/agent/durable_bootstrap.rs b/crates/zeph-core/src/agent/durable_bootstrap.rs index 6c57d0935..70b30dbe1 100644 --- a/crates/zeph-core/src/agent/durable_bootstrap.rs +++ b/crates/zeph-core/src/agent/durable_bootstrap.rs @@ -47,7 +47,7 @@ const RETENTION_TASK_NAME: &str = "durable.retention_sweep"; /// Returns `None` (after logging a `tracing::warn!`) on any I/O failure so callers degrade to /// non-durable mode rather than fail session bootstrap (#5452 FR-004). /// -/// `#[allow(clippy::too_many_arguments)]`: bundling the five key-material params into one +/// `#[allow(clippy::too_many_arguments)]`: bundling the seven key-material/seal params into one /// `DurableKeyMaterial` struct is deliberately deferred (addendum to #6451, M2) — see /// `AgentBuilder::with_durable_orchestration`'s doc for the full rationale. #[allow(clippy::too_many_arguments)] @@ -61,6 +61,7 @@ pub(crate) async fn open_durable_backend( hwm_key: Option<(u32, [u8; 32])>, previous_hmac_key: Option<[u8; 32]>, previous_hwm_key: Option<(u32, [u8; 32])>, + integrity_seal: (bool, std::collections::HashSet), ) -> Option<( Arc, JournalWriterHandle, @@ -102,6 +103,9 @@ pub(crate) async fn open_durable_backend( } else { local }; + let local = local + .with_integrity_sealed(integrity_seal.0) + .with_grandfather(integrity_seal.1); let local = Arc::new(local); let backend = Arc::new(DurableBackendEnum::Local(local.clone())); let (writer_actor, handle) = zeph_durable::JournalWriter::new(local, cfg); @@ -141,10 +145,10 @@ impl Agent { /// the session journals as a step within the *same* execution and a crash mid-session can /// resume from any prior turn's journal state. /// - /// `#[allow(clippy::too_many_lines)]`: threading `previous_hwm_key` alongside the existing - /// key-material stash (addendum to #6451) pushed this past the line budget by two lines; - /// splitting this single linear bootstrap sequence into sub-functions for that would add - /// indirection with no readability gain. + /// `#[allow(clippy::too_many_lines)]`: threading `previous_hwm_key`/`previous_hmac_key` + /// (addendum to #6451) and `integrity_seal` (#6449) alongside the existing key-material stash + /// pushed this past the line budget; splitting this single linear bootstrap sequence into + /// sub-functions for that would add indirection with no readability gain. #[allow(clippy::too_many_lines)] pub(crate) async fn ensure_session_durable_ctx(&mut self) { if self.services.session.durable_ctx.is_some() @@ -177,6 +181,13 @@ impl Agent { let hwm_key = self.services.session.durable_agent_turns_hwm_key; let previous_hmac_key = self.services.session.durable_agent_turns_previous_hmac_key; let previous_hwm_key = self.services.session.durable_agent_turns_previous_hwm_key; + let integrity_seal = ( + self.services.session.durable_agent_turns_integrity_sealed, + self.services + .session + .durable_agent_turns_integrity_grandfather + .clone(), + ); tracing::debug!("durable agent_turns: opening backend start"); let backend_result = open_durable_backend( @@ -189,6 +200,7 @@ impl Agent { hwm_key, previous_hmac_key, previous_hwm_key, + integrity_seal, ) .await; tracing::debug!("durable agent_turns: opening backend done"); @@ -453,6 +465,7 @@ mod tests { None, Some(previous_key), None, + (false, std::collections::HashSet::new()), ) .await; let (backend, _writer, _task_handle) = diff --git a/crates/zeph-core/src/agent/plan.rs b/crates/zeph-core/src/agent/plan.rs index 69bb5cfa6..5caa9ad4b 100644 --- a/crates/zeph-core/src/agent/plan.rs +++ b/crates/zeph-core/src/agent/plan.rs @@ -334,6 +334,13 @@ impl Agent { let hwm_key = self.services.orchestration.durable_hwm_key; let previous_hmac_key = self.services.orchestration.durable_previous_hmac_key; let previous_hwm_key = self.services.orchestration.durable_previous_hwm_key; + let integrity_seal = ( + self.services.orchestration.durable_integrity_sealed, + self.services + .orchestration + .durable_integrity_grandfather + .clone(), + ); let (backend, handle, task_handle) = crate::agent::durable_bootstrap::open_durable_backend( &self.runtime.lifecycle.task_supervisor, @@ -345,6 +352,7 @@ impl Agent { hwm_key, previous_hmac_key, previous_hwm_key, + integrity_seal, ) .await?; self.services.orchestration.durable_backend = Some(backend); diff --git a/crates/zeph-core/src/agent/shutdown.rs b/crates/zeph-core/src/agent/shutdown.rs index 4a01188a0..008c40525 100644 --- a/crates/zeph-core/src/agent/shutdown.rs +++ b/crates/zeph-core/src/agent/shutdown.rs @@ -289,6 +289,7 @@ impl Agent { /// /// Call this before dropping the agent to ensure no data loss. #[tracing::instrument(name = "core.agent.shutdown", skip_all, level = "debug")] + #[allow(clippy::too_many_lines)] pub async fn shutdown(&mut self) { self.channel .send_status_best_effort("Shutting down...") @@ -312,6 +313,16 @@ impl Agent { manager.shutdown_all_shared().await; } + // Anchor the session log (issue #6449): best-effort, logged rather than propagated — a + // failed anchor put only degrades this session to #6453-level chain-only protection, + // never data loss. Runs on every channel (CLI, TUI, Telegram, ACP, serve), since + // `shutdown` is the one call every channel already makes before dropping the agent. + if let Some(ref sink) = self.services.session.session_sink + && let Err(e) = sink.finalize().await + { + tracing::warn!(error = %e, "session anchor finalize failed"); + } + // Finalize compaction trajectory: push the last open segment into the Vec. // This segment would otherwise only be pushed when the next hard compaction fires, // which never happens at session end. diff --git a/crates/zeph-core/src/agent/state/mod.rs b/crates/zeph-core/src/agent/state/mod.rs index d1c16dc02..7369086c5 100644 --- a/crates/zeph-core/src/agent/state/mod.rs +++ b/crates/zeph-core/src/agent/state/mod.rs @@ -797,6 +797,11 @@ pub(crate) struct OrchestrationState { /// #6451). `Some` only while a `zeph durable rotate-key` window is open, mirroring /// `durable_previous_hmac_key` but epoch-addressed (see `durable_hwm_key`). pub(crate) durable_previous_hwm_key: Option<(u32, [u8; 32])>, + /// Whether the P2 durable backend is vault-sealed against pre-feature integrity-row absence + /// (issue #6449). `false` (the default) is the pre-cutover migration posture. + pub(crate) durable_integrity_sealed: bool, + /// Execution IDs explicitly grandfathered past the seal (issue #6449). + pub(crate) durable_integrity_grandfather: std::collections::HashSet, } /// Groups instruction hot-reload state. @@ -1017,6 +1022,13 @@ pub(crate) struct SessionState { /// key for the rotation window (addendum to #6451). `Some` only while a `zeph durable /// rotate-key` window is open (`config.previous_key_id.is_some()`). pub(crate) durable_agent_turns_previous_hwm_key: Option<(u32, [u8; 32])>, + /// Sibling companion to [`Self::durable_agent_turns_config`]: whether the P1 durable backend + /// is vault-sealed against pre-feature integrity-row absence (issue #6449). + pub(crate) durable_agent_turns_integrity_sealed: bool, + /// Sibling companion to [`Self::durable_agent_turns_config`]: execution IDs explicitly + /// grandfathered past the seal (issue #6449). + pub(crate) durable_agent_turns_integrity_grandfather: + std::collections::HashSet, /// Set to `true` the first time `ensure_session_durable_ctx` runs (success or failure) so a /// failed backend construction (missing vault key, disk error) is not retried on every turn. /// Reset to `false` by `reset_durable_ctx_for_conversation_switch` (`/new`, `/conv resume`, @@ -1486,6 +1498,8 @@ impl SessionState { durable_agent_turns_hwm_key: None, durable_agent_turns_previous_hmac_key: None, durable_agent_turns_previous_hwm_key: None, + durable_agent_turns_integrity_sealed: false, + durable_agent_turns_integrity_grandfather: std::collections::HashSet::new(), durable_ctx_init_attempted: false, durable_writer: None, durable_writer_task: None, diff --git a/crates/zeph-core/src/agent/state/tests.rs b/crates/zeph-core/src/agent/state/tests.rs index ba17c6aaa..987a2a045 100644 --- a/crates/zeph-core/src/agent/state/tests.rs +++ b/crates/zeph-core/src/agent/state/tests.rs @@ -86,6 +86,8 @@ fn make_session_state() -> SessionState { durable_agent_turns_hwm_key: None, durable_agent_turns_previous_hmac_key: None, durable_agent_turns_previous_hwm_key: None, + durable_agent_turns_integrity_sealed: false, + durable_agent_turns_integrity_grandfather: std::collections::HashSet::new(), durable_ctx_init_attempted: false, durable_writer: None, durable_writer_task: None, diff --git a/crates/zeph-core/src/anchor_store.rs b/crates/zeph-core/src/anchor_store.rs new file mode 100644 index 000000000..154141be3 --- /dev/null +++ b/crates/zeph-core/src/anchor_store.rs @@ -0,0 +1,798 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Concrete age-vault-backed [`AnchorStore`] implementation and its growth-bound sweep +//! (issue #6449). +//! +//! `zeph_common::anchor::AnchorStore` is a pure trait consumed by `zeph-subagent` and +//! `zeph-session` (INV-1: those crates never depend on `zeph-vault`). This module is the +//! concrete implementation the binary installs into each adapter's process-global slot at +//! bootstrap, exactly mirroring `history_integrity`'s role for the hash-chain key ring. +//! +//! # Vault growth bound +//! +//! [`AgeVaultProvider::save`] re-encrypts and rewrites the **entire** secrets map on every write +//! — an anchor `put` is `O(total_secrets)`, not unit cost. Since transcript anchors are already +//! bounded (deleted alongside their file by `sweep_old_transcripts`'s companion reconcile pass — +//! see below) but session anchors are deliberately never deleted on `sessions delete` (a session's +//! `events.jsonl` itself survives that command), [`run_anchor_sweep`] bounds total vault growth +//! to `O(max_session_anchors + max_transcript_files)` by: +//! +//! 1. **Reconcile**: dropping any anchor whose on-disk file/session directory no longer exists +//! (an orphan). Removing an orphan anchor is *never a false TAMPER risk* — an anchor is only +//! ever consulted when opening a file that exists — but it is **not** an unconditionally +//! benign no-op from a downgrade-resistance standpoint: under this feature's own threat model +//! (file-write access), an attacker can delete the real file/session dir for a *specific, +//! even recently-anchored* identity, wait out a sweep so the now-orphaned anchor is reaped as +//! routine maintenance, then recreate a forged legacy-looking replacement under the same +//! identity — which the read path then trusts (an absent anchor is never a tamper signature, +//! per the module docs on `zeph_common::anchor`). This is an accepted, bounded residual, not a +//! silent gap: it requires a destructive precursor (deleting the real file — itself something +//! the threat model already grants) plus waiting out a sweep window (≤ 1h, or a restart), and +//! it overlaps the already-accepted "fabricate a brand-new legacy session" no-backfill +//! residual (spec-081 FR-006) — the only difference here is reusing a *deleted* identity +//! rather than a fresh one. No mitigation (e.g. a reap grace-window, or a tombstone) is +//! implemented in this PR; flagged for a future hardening pass if the residual proves +//! unacceptable in practice. +//! 2. **Cap**: evicting the oldest session anchors (by the `written_at` field embedded *inside* +//! each AEAD-protected [`Anchor`] value — never filesystem mtime, which a file-write-only +//! attacker can freely rewrite; see the module docs on `zeph_common::anchor`) once the +//! session-anchor count exceeds `max_session_anchors`. +//! +//! Both steps decide what to remove from a brief read-locked snapshot (filesystem stats and +//! anchor decode run with **no** lock held at all), then mutate the vault's secrets map by +//! targeted key **in place** under a single write-lock scope, followed by exactly one `save()` +//! for the whole sweep — never a snapshot-modify-writeback of the *whole map*, which would +//! silently clobber a concurrent `put` racing the sweep. See [`run_anchor_sweep`] for why the +//! write lock is held only for the removal step, not the filesystem stats. + +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::{Arc, RwLock as StdRwLock}; +use std::time::Duration; + +use zeph_common::anchor::{Anchor, AnchorError, AnchorStore, AnchorSubsystem, parse_anchor_key}; +use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor}; + +use crate::vault::AgeVaultProvider; + +/// Concrete [`AnchorStore`] backed by the process's shared age vault. +/// +/// Mirrors `src/bootstrap/oauth.rs`'s `VaultCredentialStore` write pattern, upgraded to route +/// through [`TaskSupervisor::spawn_blocking`] instead of a raw `tokio::task::spawn_blocking` per +/// the CLAUDE.md async-supervision rule: every blocking age-encrypt/write must be a named, +/// observable, abortable task. +pub struct AgeVaultAnchorStore { + vault: Arc>, + supervisor: TaskSupervisor, +} + +/// Bound on [`AgeVaultAnchorStore::get_sync`]'s blocking lock-acquire (issue #6449 M1). The +/// transcript read path is plain sync (see the `AnchorStore` trait docs for why) and so cannot +/// use `tokio::time::timeout` directly — bounded instead via polling `try_read` against a +/// deadline, so a vault stall fails closed within this bound rather than blocking forever. +const ANCHOR_GET_SYNC_TIMEOUT: Duration = Duration::from_secs(5); + +/// Poll interval for the [`AgeVaultAnchorStore::get_sync`] bounded try-read loop. +const ANCHOR_GET_SYNC_POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Decode a raw vault value into an `Anchor`, shared by the sync and async `get` paths. +fn decode_anchor(value: Option<&str>) -> Result, AnchorError> { + match value { + Some(json) => serde_json::from_str(json) + .map(Some) + .map_err(|e| AnchorError::Store(format!("anchor JSON decode failed: {e}"))), + None => Ok(None), + } +} + +impl AgeVaultAnchorStore { + /// Construct a store over the process's shared age vault handle. + #[must_use] + pub fn new(vault: Arc>, supervisor: TaskSupervisor) -> Self { + Self { vault, supervisor } + } + + /// The bounded-try-read implementation behind [`AnchorStore::get_sync`], parameterized on + /// `timeout` so tests can exercise the fail-closed path without waiting out the real + /// [`ANCHOR_GET_SYNC_TIMEOUT`]. + /// + /// Never blocks indefinitely on lock contention: `std::sync::RwLock::try_read` never + /// suspends the calling thread, so polling it against a deadline (rather than calling the + /// blocking `read()`) bounds the wait even when a writer (an anchor `put`/`delete`, or + /// `run_anchor_sweep`'s `save()`) holds the lock for longer than `timeout`. + fn get_sync_bounded( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + timeout: Duration, + ) -> Result, AnchorError> { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + let deadline = std::time::Instant::now() + timeout; + loop { + match self.vault.try_read() { + Ok(guard) => return decode_anchor(guard.get(&key)), + Err(std::sync::TryLockError::Poisoned(poisoned)) => { + return decode_anchor(poisoned.into_inner().get(&key)); + } + Err(std::sync::TryLockError::WouldBlock) => { + if std::time::Instant::now() >= deadline { + return Err(AnchorError::Store(format!( + "vault read lock timed out after {timeout:?} — failing closed \ + rather than blocking indefinitely" + ))); + } + std::thread::sleep(ANCHOR_GET_SYNC_POLL_INTERVAL); + } + } + } + } +} + +impl AnchorStore for AgeVaultAnchorStore { + fn get( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Pin, AnchorError>> + Send + '_>> { + // The lock-acquire + decode MUST happen inside the polled future (via spawn_blocking), + // not synchronously here in the method prologue — otherwise a caller's + // `tokio::time::timeout(dur, store.get(..))` wraps an already-resolved future with no + // suspension point to race against, and the timeout can never fire (issue #6449 M1 + // regression: a prior version called `get_sync` here directly). + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + let vault = Arc::clone(&self.vault); + let supervisor = self.supervisor.clone(); + Box::pin(async move { + let handle = supervisor.spawn_blocking(Arc::from("anchor-get"), move || { + let guard = vault + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + decode_anchor(guard.get(&key)) + }); + handle + .join() + .await + .map_err(|e| AnchorError::Store(format!("spawn_blocking: {e}")))? + }) + } + + fn get_sync( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Result, AnchorError> { + self.get_sync_bounded(subsystem, file_id, ANCHOR_GET_SYNC_TIMEOUT) + } + + fn put( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + anchor: Anchor, + ) -> Pin> + Send + '_>> { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + let vault = Arc::clone(&self.vault); + let supervisor = self.supervisor.clone(); + Box::pin(async move { + let json = serde_json::to_string(&anchor) + .map_err(|e| AnchorError::Store(format!("anchor JSON encode failed: {e}")))?; + let handle = supervisor.spawn_blocking(Arc::from("anchor-put"), move || { + let mut guard = vault + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // An anchor is our own managed entry, always overwritten on re-finalize — + // mirrors the OAuth store's `set_secret_mut(.., true)` rationale. + guard + .set_secret_mut(key, json, true) + .map_err(|e| e.to_string())?; + guard.save().map_err(|e| e.to_string()) + }); + handle + .join() + .await + .map_err(|e| AnchorError::Store(format!("spawn_blocking: {e}")))? + .map_err(AnchorError::Store) + }) + } + + fn delete( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Pin> + Send + '_>> { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + let vault = Arc::clone(&self.vault); + let supervisor = self.supervisor.clone(); + Box::pin(async move { + let handle = supervisor.spawn_blocking(Arc::from("anchor-delete"), move || { + let mut guard = vault + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !guard.remove_secret_mut(&key) { + return Ok(()); // absent — a no-op, not an error + } + guard.save().map_err(|e| e.to_string()) + }); + handle + .join() + .await + .map_err(|e| AnchorError::Store(format!("spawn_blocking: {e}")))? + .map_err(AnchorError::Store) + }) + } +} + +/// Install (or uninstall, with `store = None`) the vault-anchor store into both adapter crates' +/// process-global slots (issue #6449). Mirrors +/// `zeph_subagent::transcript::configure_history_integrity`'s single-set-at-startup contract. +pub fn install_anchor_store(store: Option>) { + zeph_subagent::transcript::configure_anchor_store(store.clone()); + zeph_session::log::configure_anchor_store(store); +} + +/// Outcome of one [`run_anchor_sweep`] pass, for logging and tests. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AnchorSweepReport { + /// Orphaned anchors removed (on-disk file/session directory no longer exists). + pub orphans_reaped: usize, + /// Session anchors evicted because the session-anchor count exceeded the cap. + pub evicted_for_cap: usize, +} + +/// Run one reconcile-and-cap pass over the vault's `ZEPH_HISTORY_ANCHOR_*` keys (issue #6449). +/// +/// Synchronous and blocking (file-existence checks + vault mutation) — callers on an async path +/// must dispatch this through [`TaskSupervisor::spawn_blocking`], never call it inline. +/// +/// The vault **write** lock is held only for the final targeted `remove_secret_mut` calls plus +/// `save()` — every `Path::exists()` stat and anchor JSON decode runs against a snapshot taken +/// under a brief **read** lock, released before any filesystem I/O. Holding the write lock across +/// `O(total anchors)` stat syscalls would otherwise stall every concurrent anchor `get`/`put` for +/// the duration of the sweep (perf finding, same root cause class as issue #6449 M1). This is +/// still "in place" per M-sweep-inplace: removal is always by targeted key +/// (`remove_secret_mut`), never a whole-map snapshot-modify-writeback, so a concurrent `put` for +/// an unrelated key is never clobbered. +/// +/// # Errors +/// +/// Returns a description string if the final `save()` (only performed when at least one entry +/// was removed) fails. +pub fn run_anchor_sweep( + vault: &Arc>, + transcript_dir: &Path, + sessions_data_dir: &Path, + max_session_anchors: usize, +) -> Result { + // Step 1: snapshot every anchor key + raw value under a brief READ lock — no I/O here. + let snapshot: Vec<(String, String)> = { + let guard = vault + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + guard + .list_keys() + .into_iter() + .filter(|k| k.starts_with(zeph_common::anchor::ANCHOR_KEY_PREFIX)) + .filter_map(|k| guard.get(k).map(|v| (k.to_owned(), v.to_owned()))) + .collect() + }; // read guard dropped here, before any filesystem stat + + // Step 2: decide what to remove — filesystem stats and JSON decode, no lock held at all. + let mut orphans: Vec = Vec::new(); + let mut live_session_anchors: Vec<(String, u64)> = Vec::new(); + for (key, json) in &snapshot { + let Some((subsystem, file_id)) = parse_anchor_key(key) else { + continue; // not a well-formed anchor key — leave it alone + }; + let file_id_str = String::from_utf8_lossy(&file_id).into_owned(); + let exists = match subsystem { + AnchorSubsystem::SubagentTranscript => { + transcript_dir.join(format!("{file_id_str}.jsonl")).exists() + } + AnchorSubsystem::SessionLog => { + zeph_session::session_dir(sessions_data_dir, &file_id_str).exists() + } + }; + if !exists { + orphans.push(key.clone()); + continue; + } + if subsystem == AnchorSubsystem::SessionLog { + let written_at = serde_json::from_str::(json) + .ok() + .map_or(0, |a| a.written_at); + live_session_anchors.push((key.clone(), written_at)); + } + } + + let mut evictions: Vec = Vec::new(); + if live_session_anchors.len() > max_session_anchors { + live_session_anchors.sort_by_key(|(_, written_at)| *written_at); + let to_evict = live_session_anchors.len() - max_session_anchors; + evictions.extend( + live_session_anchors + .into_iter() + .take(to_evict) + .map(|(key, _)| key), + ); + } + + // Step 3: acquire the WRITE lock only for the targeted removes + one save(). + let mut report = AnchorSweepReport::default(); + if !orphans.is_empty() || !evictions.is_empty() { + let mut guard = vault + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for key in &orphans { + if guard.remove_secret_mut(key) { + report.orphans_reaped += 1; + } + } + for key in &evictions { + if guard.remove_secret_mut(key) { + report.evicted_for_cap += 1; + } + } + guard.save().map_err(|e| e.to_string())?; + } + + Ok(report) +} + +/// Low-frequency periodic tick for [`run_anchor_sweep`] (issue #6449 rev2 critic: startup + +/// periodic is required — a long-running server/gateway process would otherwise let the vault +/// map exceed the cap unbounded between restarts). +const SWEEP_INTERVAL: Duration = Duration::from_hours(1); + +/// Spawn the reconcile-and-cap sweep as a named, supervised task (issue #6449): runs once +/// immediately, then on an hourly interval thereafter, for the life of the process. +/// +/// Each tick's blocking work is dispatched through [`TaskSupervisor::spawn_blocking`] from +/// within the supervised task's own async body — never inline on the tokio worker thread. +pub fn spawn_anchor_sweep( + supervisor: &TaskSupervisor, + vault: Arc>, + transcript_dir: PathBuf, + sessions_data_dir: PathBuf, + max_session_anchors: usize, +) { + let sweep_supervisor = supervisor.clone(); + supervisor.spawn(TaskDescriptor { + name: "anchor-reconcile-sweep", + restart: RestartPolicy::RunOnce, + factory: move || { + let vault = Arc::clone(&vault); + let transcript_dir = transcript_dir.clone(); + let sessions_data_dir = sessions_data_dir.clone(); + let blocking = sweep_supervisor.clone(); + async move { + let tick = move |vault: Arc>, + transcript_dir: PathBuf, + sessions_data_dir: PathBuf, + blocking: TaskSupervisor| async move { + let handle = + blocking.spawn_blocking(Arc::from("anchor-sweep-tick"), move || { + run_anchor_sweep( + &vault, + &transcript_dir, + &sessions_data_dir, + max_session_anchors, + ) + }); + match handle.join().await { + Ok(Ok(report)) + if report.orphans_reaped > 0 || report.evicted_for_cap > 0 => + { + tracing::info!( + orphans_reaped = report.orphans_reaped, + evicted_for_cap = report.evicted_for_cap, + "anchor reconcile-and-cap sweep completed" + ); + } + Ok(Ok(_)) => {} + Ok(Err(e)) => tracing::warn!(error = %e, "anchor sweep failed"), + Err(e) => tracing::warn!(error = %e, "anchor sweep task failed"), + } + }; + + tick( + Arc::clone(&vault), + transcript_dir.clone(), + sessions_data_dir.clone(), + blocking.clone(), + ) + .await; + + let mut interval = tokio::time::interval(SWEEP_INTERVAL); + interval.tick().await; // first tick fires immediately; already ran once above + loop { + interval.tick().await; + tick( + Arc::clone(&vault), + transcript_dir.clone(), + sessions_data_dir.clone(), + blocking.clone(), + ) + .await; + } + } + }, + }); +} + +/// Resolve the vault-stored durable integrity seal marker + grandfather set (issue #6449) for +/// `crate::agent::durable_bootstrap` to attach to a `zeph_durable::backend::LocalBackend`. +/// +/// Presence of `ZEPH_DURABLE_INTEGRITY_SEALED` means sealed; its value (if any) is a +/// human-readable timestamp for `doctor` display only, never on the security boundary. +#[must_use] +pub fn load_durable_integrity_seal( + provider: &AgeVaultProvider, +) -> (bool, HashSet) { + let sealed = provider.get(DURABLE_INTEGRITY_SEALED_KEY).is_some(); + let grandfather = provider + .get(DURABLE_INTEGRITY_GRANDFATHER_KEY) + .map(parse_grandfather_set) + .unwrap_or_default(); + (sealed, grandfather) +} + +/// Vault secret name whose *presence* marks a durable backend sealed against pre-feature +/// integrity-row absence (issue #6449). The value, if any, is a display-only timestamp. +pub const DURABLE_INTEGRITY_SEALED_KEY: &str = "ZEPH_DURABLE_INTEGRITY_SEALED"; + +/// Vault secret name for the comma-separated set of execution IDs grandfathered past the seal +/// (issue #6449). +pub const DURABLE_INTEGRITY_GRANDFATHER_KEY: &str = "ZEPH_DURABLE_INTEGRITY_GRANDFATHER"; + +/// Parse a comma-separated grandfather-set vault value into execution IDs, skipping any entry +/// that fails to parse (defensive — a hand-edited vault value should degrade, not panic). +#[must_use] +pub fn parse_grandfather_set(value: &str) -> HashSet { + value + .split(',') + .filter_map(|s| zeph_durable::ExecutionId::parse_str(s.trim()).ok()) + .collect() +} + +/// Render a grandfather set back to the vault's comma-separated storage format, merging with +/// any IDs already present in `existing` (each grandfathered id is a permanent addition — see +/// the module docs on `zeph_durable::backend::local::LocalBackend::with_grandfather` for the +/// accepted residual this carries). +#[must_use] +#[allow(clippy::implicit_hasher)] +pub fn render_grandfather_set( + existing: &str, + new_ids: &HashSet, +) -> String { + let mut all: HashSet = parse_grandfather_set(existing); + all.extend(new_ids.iter().copied()); + let mut ids: Vec = all.iter().map(|id| id.as_uuid().to_string()).collect(); + ids.sort_unstable(); + ids.join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::sync::CancellationToken; + use zeph_common::anchor::AnchorSubsystem; + + fn test_vault(dir: &Path) -> Arc> { + AgeVaultProvider::init_vault(dir).unwrap(); + let provider = + AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age")).unwrap(); + Arc::new(StdRwLock::new(provider)) + } + + #[tokio::test] + async fn put_get_delete_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let supervisor = TaskSupervisor::new(CancellationToken::new()); + let store = AgeVaultAnchorStore::new(vault, supervisor); + + let head = zeph_common::hash_chain::chain_next( + &zeph_common::hash_chain::ChainKey::new([1u8; 32]), + &zeph_common::hash_chain::genesis( + &zeph_common::hash_chain::ChainKey::new([1u8; 32]), + "d", + b"f", + 0, + ), + b"content", + ); + let anchor = Anchor::new(0, 5, head); + + assert!( + store + .get(AnchorSubsystem::SubagentTranscript, b"task-1") + .await + .unwrap() + .is_none() + ); + + store + .put( + AnchorSubsystem::SubagentTranscript, + b"task-1", + anchor.clone(), + ) + .await + .unwrap(); + + let fetched = store + .get(AnchorSubsystem::SubagentTranscript, b"task-1") + .await + .unwrap() + .unwrap(); + assert_eq!(fetched.count, 5); + assert_eq!(fetched.head_hex, anchor.head_hex); + + store + .delete(AnchorSubsystem::SubagentTranscript, b"task-1") + .await + .unwrap(); + assert!( + store + .get(AnchorSubsystem::SubagentTranscript, b"task-1") + .await + .unwrap() + .is_none() + ); + } + + /// Regression test for issue #6449 M1: an external `tokio::time::timeout` wrapping + /// `AnchorStore::get` must actually fire while the vault write lock is held (e.g. a + /// concurrent `put`/sweep `save()` in progress), never hang past it. Before the fix, + /// `get()` called `get_sync` synchronously in its own body before constructing the + /// returned future, so `store.get(..)` fully resolved (or deadlocked) as a plain + /// expression *before* `tokio::time::timeout` was ever invoked — the timeout wrapped an + /// already-resolved future with no suspension point to race against. With the bug present, + /// this test would hang forever (the writer is never released until *after* the awaited + /// call returns), not just fail an assertion — the outer `tokio::time::timeout` around the + /// whole test body is a safety net so a regression fails loudly instead of hanging CI. + #[tokio::test] + async fn get_is_a_real_suspension_point_and_honors_an_external_timeout() { + let outcome = tokio::time::timeout(Duration::from_secs(5), async { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let supervisor = TaskSupervisor::new(CancellationToken::new()); + let store = AgeVaultAnchorStore::new(Arc::clone(&vault), supervisor); + + // Hold the write lock on a background OS thread, simulating a slow `save()` (a + // concurrent `put`/`delete`/sweep) in progress. + let (held_tx, held_rx) = std::sync::mpsc::channel::<()>(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let vault_for_holder = Arc::clone(&vault); + let holder = std::thread::spawn(move || { + let _guard = vault_for_holder.write().unwrap(); + held_tx.send(()).unwrap(); + release_rx.recv().unwrap(); // block until the test tells us to release + }); + held_rx.recv().unwrap(); // wait until the writer genuinely holds the lock + + let result = tokio::time::timeout( + Duration::from_millis(100), + store.get(AnchorSubsystem::SubagentTranscript, b"whatever"), + ) + .await; + assert!( + result.is_err(), + "the external 100ms timeout must fire while the write lock is held — a real \ + suspension point must exist for it to race against" + ); + + release_tx.send(()).unwrap(); + holder.join().unwrap(); + }) + .await; + assert!( + outcome.is_ok(), + "test itself must not hang past its 5s safety-net timeout" + ); + } + + /// Regression test for issue #6449 M1 on the transcript (sync) read path: `get_sync` must + /// fail closed under sustained vault write-lock contention rather than blocking forever. + #[test] + fn get_sync_bounded_times_out_under_contention_instead_of_hanging() { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let supervisor = TaskSupervisor::new(CancellationToken::new()); + let store = AgeVaultAnchorStore::new(Arc::clone(&vault), supervisor); + + let (held_tx, held_rx) = std::sync::mpsc::channel::<()>(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let vault_for_holder = Arc::clone(&vault); + let holder = std::thread::spawn(move || { + let _guard = vault_for_holder.write().unwrap(); + held_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }); + held_rx.recv().unwrap(); + + let start = std::time::Instant::now(); + let result = store.get_sync_bounded( + AnchorSubsystem::SubagentTranscript, + b"whatever", + Duration::from_millis(50), + ); + let elapsed = start.elapsed(); + + assert!( + result.is_err(), + "must fail closed, not hang, under contention" + ); + assert!( + elapsed < Duration::from_secs(2), + "must return close to the 50ms bound, not block indefinitely (took {elapsed:?})" + ); + + release_tx.send(()).unwrap(); + holder.join().unwrap(); + } + + /// Once the writer releases, a bounded lookup must still succeed normally (the bound only + /// gates the contended case, never rejects an uncontended or since-released read). + #[test] + fn get_sync_bounded_succeeds_once_contention_clears() { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let supervisor = TaskSupervisor::new(CancellationToken::new()); + let store = AgeVaultAnchorStore::new(Arc::clone(&vault), supervisor); + + let (held_tx, held_rx) = std::sync::mpsc::channel::<()>(); + let vault_for_holder = Arc::clone(&vault); + let holder = std::thread::spawn(move || { + let _guard = vault_for_holder.write().unwrap(); + std::thread::sleep(Duration::from_millis(50)); + held_tx.send(()).unwrap(); + }); + + let result = store.get_sync_bounded( + AnchorSubsystem::SubagentTranscript, + b"whatever", + Duration::from_secs(2), + ); + assert!( + result.is_ok(), + "a bound long enough to outlast contention must still succeed" + ); + + held_rx.recv().unwrap(); + holder.join().unwrap(); + } + + fn sample_anchor(count: u64) -> Anchor { + let key = zeph_common::hash_chain::ChainKey::new([2u8; 32]); + let base = zeph_common::hash_chain::genesis(&key, "d", b"f", 0); + let head = zeph_common::hash_chain::chain_next(&key, &base, b"c"); + Anchor::new(0, count, head) + } + + #[test] + fn sweep_reaps_orphan_transcript_anchor() { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let transcript_dir = dir.path().join("transcripts"); + let sessions_dir = dir.path().join("sessions"); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::create_dir_all(&sessions_dir).unwrap(); + + { + let mut guard = vault.write().unwrap(); + let key = zeph_common::anchor::anchor_key(AnchorSubsystem::SubagentTranscript, b"gone"); + let json = serde_json::to_string(&sample_anchor(1)).unwrap(); + guard.set_secret_mut(key, json, true).unwrap(); + guard.save().unwrap(); + } + + let report = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512).unwrap(); + assert_eq!(report.orphans_reaped, 1); + assert!(vault.read().unwrap().list_keys().is_empty()); + } + + #[test] + fn sweep_keeps_anchor_whose_file_exists() { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let transcript_dir = dir.path().join("transcripts"); + let sessions_dir = dir.path().join("sessions"); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::write(transcript_dir.join("alive.jsonl"), b"").unwrap(); + + { + let mut guard = vault.write().unwrap(); + let key = + zeph_common::anchor::anchor_key(AnchorSubsystem::SubagentTranscript, b"alive"); + let json = serde_json::to_string(&sample_anchor(1)).unwrap(); + guard.set_secret_mut(key, json, true).unwrap(); + guard.save().unwrap(); + } + + let report = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512).unwrap(); + assert_eq!(report.orphans_reaped, 0); + assert_eq!(vault.read().unwrap().list_keys().len(), 1); + } + + /// S3 regression: eviction must order by the anchor-embedded `written_at`, never by + /// filesystem mtime (attacker-writable). + #[test] + fn sweep_caps_session_anchors_by_embedded_written_at_not_mtime() { + let dir = tempfile::tempdir().unwrap(); + let vault = test_vault(dir.path()); + let transcript_dir = dir.path().join("transcripts"); + let sessions_dir = dir.path().join("sessions"); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::create_dir_all(&sessions_dir).unwrap(); + + // Three sessions, each with a session directory on disk (so none are orphans) and an + // anchor whose `written_at` we control directly (bypassing wall-clock timing). + // Directories are deliberately created in the OPPOSITE order of `written_at` + // ("s-new" first, "s-old" last), so filesystem mtime order is inverted relative to + // anchor-content order — simulating an attacker who manipulates mtime (or simply the + // natural case of a session directory touched more recently than its anchor's last + // `written_at`). If eviction used mtime, it would wrongly evict "s-new" or "s-mid" + // instead of the true oldest, "s-old". + let mut anchors_by_name = Vec::new(); + for (name, written_at) in [("s-new", 300u64), ("s-mid", 200), ("s-old", 100)] { + let session_path = zeph_session::session_dir(&sessions_dir, name); + std::fs::create_dir_all(&session_path).unwrap(); + let mut anchor = sample_anchor(1); + anchor.written_at = written_at; + anchors_by_name.push((name, anchor)); + } + + { + let mut guard = vault.write().unwrap(); + for (name, anchor) in &anchors_by_name { + let key = + zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, name.as_bytes()); + let json = serde_json::to_string(anchor).unwrap(); + guard.set_secret_mut(key, json, true).unwrap(); + } + guard.save().unwrap(); + } + + // Cap at 2: must evict "s-old" (written_at=100), the true oldest by anchor content — + // NOT whichever mtime manipulation would suggest. + let report = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 2).unwrap(); + assert_eq!(report.evicted_for_cap, 1); + + let remaining_keys: Vec = vault + .read() + .unwrap() + .list_keys() + .into_iter() + .map(str::to_owned) + .collect(); + let old_key = zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, b"s-old"); + let mid_key = zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, b"s-mid"); + let new_key = zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, b"s-new"); + assert!( + !remaining_keys.contains(&old_key), + "the true oldest must be evicted" + ); + assert!(remaining_keys.contains(&mid_key)); + assert!(remaining_keys.contains(&new_key)); + } + + #[test] + fn grandfather_set_round_trips_and_merges() { + let a = zeph_durable::ExecutionId::new(); + let b = zeph_durable::ExecutionId::new(); + let rendered = render_grandfather_set("", &HashSet::from([a])); + let parsed = parse_grandfather_set(&rendered); + assert!(parsed.contains(&a)); + + // Merging must be additive — an existing id is never dropped. + let rendered2 = render_grandfather_set(&rendered, &HashSet::from([b])); + let parsed2 = parse_grandfather_set(&rendered2); + assert!(parsed2.contains(&a)); + assert!(parsed2.contains(&b)); + } +} diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index 38183c898..0922496ea 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -70,6 +70,7 @@ pub mod agent; #[cfg(feature = "profiling-alloc")] pub mod alloc_layer; +pub mod anchor_store; pub mod channel; pub mod config; pub mod config_watcher; diff --git a/crates/zeph-durable/README.md b/crates/zeph-durable/README.md index d284feac9..0dcc7d63f 100644 --- a/crates/zeph-durable/README.md +++ b/crates/zeph-durable/README.md @@ -94,9 +94,16 @@ a dedicated `durable.db` (SQLite) or a feature-gated Restate backend. committed_result_count, key_epoch}` tuple, verified O(1) on every resume — that detects deletion of a committed `StepResult` row, including across a `checkpoint_fold` compaction. It activates unconditionally whenever `ZEPH_DURABLE_KEY` is provisioned, unlike the row-HMAC above - which stays opt-in for shared-database deployments. Detects deletion/forgery of tracked - entries; does not yet resist an attacker deleting the HWM row itself (treated as legacy for - migration safety) — tracked in issue #6449. + which stays opt-in for shared-database deployments. +- **Downgrade-resistant, vault-sealed (issue #6449).** `LocalBackend::with_integrity_sealed`/ + `with_grandfather` close the gap the HWM alone left open: deleting the whole + `durable_execution_integrity` row used to be trusted as "predates the feature." Once an + operator runs `zeph durable seal-integrity` (which refuses while any resumable execution has + committed results but no integrity row), an absent row on a keyed, non-grandfathered execution + with ≥1 committed `StepResult` is unconditional tamper — the seal marker and grandfather set + are vault-stored, never a DB column, so a DB-write attacker cannot forge or evade them. + Residual: a grandfathered `execution_id` remains a *permanent* forge-able slot (an explicit, + documented operator opt-out, not free protection) — prefer draining where practical. > [!NOTE] > **Schema ownership (INV-14).** `zeph-durable` owns **no** `.sql` files and **no** diff --git a/crates/zeph-durable/src/backend/local.rs b/crates/zeph-durable/src/backend/local.rs index 0e8367387..718a16a87 100644 --- a/crates/zeph-durable/src/backend/local.rs +++ b/crates/zeph-durable/src/backend/local.rs @@ -172,6 +172,21 @@ pub struct LocalBackend { /// `lock_dir = None` backend (#6254), so a background retention tick every /// `prune_interval_secs` does not spam the log for the lifetime of the process. orphan_sweep_warned: std::sync::atomic::AtomicBool, + /// Vault-sealed integrity marker (issue #6449). `true` only when the *presence* of + /// `ZEPH_DURABLE_INTEGRITY_SEALED` in the vault was confirmed at bootstrap — an + /// attacker with DB write access cannot set this to `true` (it is never derived from any DB + /// column). Once sealed, [`check_high_water_mark`](Self::check_high_water_mark) treats an + /// absent integrity row on a keyed, non-grandfathered execution with committed `StepResult`s + /// as unconditional tamper, closing the pre-seal migration posture's downgrade lever. + integrity_sealed: bool, + /// Execution IDs explicitly grandfathered past the seal via `zeph durable seal-integrity + /// --grandfather` (issue #6449) — a vault-stored, unforgeable-by-DB-write set. Each entry is + /// a **permanent** opt-out for that one execution (not merely a frozen pre-seal snapshot): an + /// attacker with DB write access can delete-and-reinsert forged content under the same + /// grandfathered `execution_id` and it will still resume unverified. This is an accepted, + /// bounded, documented residual of the opt-out — operators should prefer draining a + /// resumable execution to a terminal state over grandfathering it. + integrity_grandfather: std::collections::HashSet, } /// One row-HMAC/high-water-mark key, addressed by its non-secret rotation epoch (FR-008). @@ -221,6 +236,8 @@ impl LocalBackend { timer_waiters: NotifyRegistry::default(), lock_dir: None, orphan_sweep_warned: std::sync::atomic::AtomicBool::new(false), + integrity_sealed: false, + integrity_grandfather: std::collections::HashSet::new(), } } @@ -305,6 +322,28 @@ impl LocalBackend { self } + /// Configure whether this backend has been sealed against pre-feature integrity-row absence + /// (issue #6449). Pass `true` only when the vault-stored `ZEPH_DURABLE_INTEGRITY_SEALED` + /// marker's *presence* was confirmed at bootstrap — never derive this from any DB column + /// (that was the S1 defeat the vault-sealed design fixes; see `check_high_water_mark`'s + /// doc). + #[must_use] + pub fn with_integrity_sealed(mut self, sealed: bool) -> Self { + self.integrity_sealed = sealed; + self + } + + /// Register the vault-stored set of execution IDs grandfathered past the integrity seal + /// (issue #6449). Each grandfathered id is a *permanent* forge-able slot (not merely a + /// frozen pre-existing posture): an attacker with DB write access can delete and re-insert + /// forged content under the same id. This is an accepted, bounded, documented operator + /// opt-out — prefer draining a resumable execution to a terminal status where practical. + #[must_use] + pub fn with_grandfather(mut self, ids: std::collections::HashSet) -> Self { + self.integrity_grandfather = ids; + self + } + /// Borrow the underlying pool (for tests and adapters that need direct access). #[must_use] pub fn pool(&self) -> &DbPool { @@ -2078,14 +2117,97 @@ impl LocalBackend { Ok(()) } + /// Find every **resumable** (`status = 'running'`) execution that has committed at least one + /// `StepResult` but carries no `durable_execution_integrity` row (issue #6449). + /// + /// This is the drain-before-seal precondition scan for `zeph durable seal-integrity`: the + /// returned set is exactly the executions that would be silently downgraded to + /// unconditional-tamper the moment this backend seals, unless drained to a terminal status + /// first or explicitly grandfathered. A non-resumable (terminal) execution missing its row is + /// not a concern — it can never be resumed again, sealed or not. + /// + /// # Errors + /// + /// Returns [`DurableError::Storage`] if the query fails. + pub async fn find_unsealed_resumable_executions( + &self, + ) -> Result, DurableError> { + let rows: Vec<(String,)> = zeph_db::query_as(sql!( + "SELECT e.execution_id FROM durable_executions e + WHERE e.status = 'running' + AND NOT EXISTS ( + SELECT 1 FROM durable_execution_integrity i WHERE i.execution_id = e.execution_id + ) + AND ( + EXISTS ( + SELECT 1 FROM durable_journal j + WHERE j.execution_id = e.execution_id AND j.entry_kind = 'step_result' + ) + OR EXISTS ( + SELECT 1 FROM durable_journal j + WHERE j.execution_id = e.execution_id AND j.entry_kind = 'checkpoint' + AND j.folded_count > 0 + ) + )" + )) + .fetch_all(&self.pool) + .await + .map_err(|e| DurableError::storage("seal_integrity_scan", e))?; + + rows.into_iter() + .map(|(id,)| { + ExecutionId::parse_str(&id).map_err(|_| DurableError::Decode { + context: "malformed execution_id in durable_executions", + }) + }) + .collect() + } + + /// Recompute the number of committed `StepResult`s for `execution_id` directly from the + /// journal: surviving `step_result` rows plus every checkpoint's `folded_count` (a fold moves + /// committed results into a checkpoint snapshot net-zero, so this sum is invariant across + /// folding). Shared by [`check_high_water_mark`](Self::check_high_water_mark)'s present-row + /// recomputation and its post-seal absent-row check (issue #6449). + async fn committed_step_result_count( + &self, + execution_id: ExecutionId, + ) -> Result { + let exec = execution_id.as_uuid().to_string(); + let live_count: i64 = zeph_db::query_scalar(sql!( + "SELECT COUNT(*) FROM durable_journal + WHERE execution_id = ? AND entry_kind = 'step_result'" + )) + .bind(&exec) + .fetch_one(&self.pool) + .await + .map_err(|e| DurableError::storage("hwm_verify", e))?; + let folded_sum: i64 = zeph_db::query_scalar(sql!( + "SELECT COALESCE(SUM(folded_count), 0) FROM durable_journal + WHERE execution_id = ? AND entry_kind = 'checkpoint'" + )) + .bind(&exec) + .fetch_one(&self.pool) + .await + .map_err(|e| DurableError::storage("hwm_verify", e))?; + Ok(u64::try_from(live_count.saturating_add(folded_sum)).unwrap_or(0)) + } + /// The comparison half of `verify_high_water_mark`. /// - /// Absent a stored `durable_execution_integrity` row, this execution predates the feature or - /// has committed no `StepResult` yet — nothing to compare against, so it is accepted (migration - /// posture: only a row's total absence is legacy, mirroring the JSONL side's "no chain metadata - /// at all" lane). A *present* row is always fully verified: an unresolvable `key_epoch`, an - /// HMAC that does not authenticate, or a recomputed `committed_result_count` that disagrees - /// with the signed value are each a distinct fail-closed [`DurableError::HighWaterMarkIntegrity`]. + /// Absent a stored `durable_execution_integrity` row: **pre-seal** (or unkeyed), this + /// execution predates the feature or has committed no `StepResult` yet — nothing to compare + /// against, so it is accepted (migration posture: only a row's total absence is legacy, + /// mirroring the JSONL side's "no chain metadata at all" lane). **Post-seal** (issue #6449 — + /// `integrity_sealed == true`, confirmed via the vault-stored `ZEPH_DURABLE_INTEGRITY_SEALED` + /// marker, never a DB column), a keyed, non-grandfathered execution with at least one + /// committed `StepResult` but no integrity row is unconditional tamper: the drain-before-seal + /// precondition on `zeph durable seal-integrity` guarantees no execution can reach this state + /// legitimately once sealed (the keyed integrity-row write is atomic-in-transaction with the + /// `StepResult` commit, so "committed result present, row absent" cannot occur for anything + /// that started after the vault key was attached). A *present* row is always fully verified: + /// an unresolvable `key_epoch`, an HMAC that does not authenticate, or a recomputed + /// `committed_result_count` that disagrees with the signed value are each a distinct + /// fail-closed [`DurableError::HighWaterMarkIntegrity`]. async fn check_high_water_mark(&self, execution_id: ExecutionId) -> Result<(), DurableError> { let exec = execution_id.as_uuid().to_string(); let stored: Option<(i64, i64, i64, Vec)> = zeph_db::query_as(sql!( @@ -2097,6 +2219,22 @@ impl LocalBackend { .await .map_err(|e| DurableError::storage("hwm_verify", e))?; let Some((epoch_raw, max_step_raw, count_raw, hmac)) = stored else { + if self.hwm_key.is_some() + && self.integrity_sealed + && !self.integrity_grandfather.contains(&execution_id) + && self.committed_step_result_count(execution_id).await? >= 1 + { + return Err(DurableError::HighWaterMarkIntegrity { + execution_id, + reason: "integrity_row_absent_post_seal", + hint: "TAMPER: this backend is sealed against pre-feature integrity-row \ + absence, this execution is keyed and not grandfathered, and it has \ + committed StepResults — a legitimate keyed execution can never reach \ + this state (the integrity row is written atomically with its first \ + committed StepResult), so an absent row here means the row was \ + deleted outside the write path", + }); + } return Ok(()); }; @@ -2139,23 +2277,7 @@ impl LocalBackend { return Err(tamper("hmac_mismatch")); } - let live_count: i64 = zeph_db::query_scalar(sql!( - "SELECT COUNT(*) FROM durable_journal - WHERE execution_id = ? AND entry_kind = 'step_result'" - )) - .bind(&exec) - .fetch_one(&self.pool) - .await - .map_err(|e| DurableError::storage("hwm_verify", e))?; - let folded_sum: i64 = zeph_db::query_scalar(sql!( - "SELECT COALESCE(SUM(folded_count), 0) FROM durable_journal - WHERE execution_id = ? AND entry_kind = 'checkpoint'" - )) - .bind(&exec) - .fetch_one(&self.pool) - .await - .map_err(|e| DurableError::storage("hwm_verify", e))?; - let recomputed = u64::try_from(live_count.saturating_add(folded_sum)).unwrap_or(0); + let recomputed = self.committed_step_result_count(execution_id).await?; if recomputed != count { return Err(fail( "count_mismatch", @@ -5429,6 +5551,239 @@ mod tests { ); } + // --- Vault-sealed integrity boundary tests (issue #6449) --- + + #[tokio::test] + async fn hwm_unsealed_absent_row_after_deletion_is_still_ok() { + // A keyed but *unsealed* backend (the pre-#6449-cutover posture): even after a committed + // StepResult's integrity row is deleted, resume must still succeed — the migration + // posture unless/until an operator explicitly seals. + let backend = mem_backend(1_048_576).await.with_hwm_key(0, [30u8; 32]); + let exec = ExecutionId::new(); + backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(); + backend.append(step_result(exec, 0, b"v0")).await.unwrap(); + + zeph_db::query(sql!( + "DELETE FROM durable_execution_integrity WHERE execution_id = ?" + )) + .bind(exec.as_uuid().to_string()) + .execute(backend.pool()) + .await + .unwrap(); + + assert!( + backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(), + "unsealed backend must not treat an absent integrity row as tamper" + ); + } + + #[tokio::test] + async fn hwm_post_seal_absent_row_with_committed_results_is_tamper() { + let backend = mem_backend(1_048_576) + .await + .with_hwm_key(0, [31u8; 32]) + .with_integrity_sealed(true); + let exec = ExecutionId::new(); + backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(); + backend.append(step_result(exec, 0, b"v0")).await.unwrap(); + + // Attacker (DB write access) deletes the integrity row, keeping the committed + // StepResult in place to replay it. + zeph_db::query(sql!( + "DELETE FROM durable_execution_integrity WHERE execution_id = ?" + )) + .bind(exec.as_uuid().to_string()) + .execute(backend.pool()) + .await + .unwrap(); + + let err = backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap_err(); + assert_matches!( + err, + DurableError::HighWaterMarkIntegrity { + reason: "integrity_row_absent_post_seal", + .. + } + ); + } + + #[tokio::test] + async fn hwm_post_seal_forged_created_at_does_not_evade_the_seal() { + // Proves S1 is fully closed: the boundary no longer consults `created_at` at all, so + // an attacker forging it (the rev1 defeat) has no effect once sealed. + let backend = mem_backend(1_048_576) + .await + .with_hwm_key(0, [32u8; 32]) + .with_integrity_sealed(true); + let exec = ExecutionId::new(); + backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(); + backend.append(step_result(exec, 0, b"v0")).await.unwrap(); + + zeph_db::query(sql!( + "UPDATE durable_executions SET created_at = 0 WHERE execution_id = ?" + )) + .bind(exec.as_uuid().to_string()) + .execute(backend.pool()) + .await + .unwrap(); + zeph_db::query(sql!( + "DELETE FROM durable_execution_integrity WHERE execution_id = ?" + )) + .bind(exec.as_uuid().to_string()) + .execute(backend.pool()) + .await + .unwrap(); + + let err = backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap_err(); + assert_matches!( + err, + DurableError::HighWaterMarkIntegrity { + reason: "integrity_row_absent_post_seal", + .. + }, + "forging created_at must not evade the seal — it is never consulted" + ); + } + + #[tokio::test] + async fn hwm_grandfathered_execution_absent_row_is_ok() { + let exec = ExecutionId::new(); + let writer = mem_backend(1_048_576).await.with_hwm_key(0, [33u8; 32]); + writer + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(); + writer.append(step_result(exec, 0, b"v0")).await.unwrap(); + zeph_db::query(sql!( + "DELETE FROM durable_execution_integrity WHERE execution_id = ?" + )) + .bind(exec.as_uuid().to_string()) + .execute(writer.pool()) + .await + .unwrap(); + + let sealed_but_grandfathered = LocalBackend::new(writer.pool().clone(), 1_048_576) + .with_hwm_key(0, [33u8; 32]) + .with_integrity_sealed(true) + .with_grandfather(std::collections::HashSet::from([exec])); + + assert!( + sealed_but_grandfathered + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(), + "a grandfathered execution_id must resume despite the seal" + ); + } + + #[tokio::test] + async fn find_unsealed_resumable_executions_finds_only_the_offending_set() { + let backend = mem_backend(1_048_576).await.with_hwm_key(0, [35u8; 32]); + + // (a) running, keyed, committed StepResult, integrity row deleted — the offending case. + let offending = ExecutionId::new(); + backend + .open_execution(offending, ExecutionKind::AgentTurn) + .await + .unwrap(); + backend + .append(step_result(offending, 0, b"v0")) + .await + .unwrap(); + zeph_db::query(sql!( + "DELETE FROM durable_execution_integrity WHERE execution_id = ?" + )) + .bind(offending.as_uuid().to_string()) + .execute(backend.pool()) + .await + .unwrap(); + + // (b) running, keyed, has an intact integrity row — not offending. + let intact = ExecutionId::new(); + backend + .open_execution(intact, ExecutionKind::AgentTurn) + .await + .unwrap(); + backend.append(step_result(intact, 0, b"v0")).await.unwrap(); + + // (c) running, no committed results at all — not offending (nothing to smuggle). + let empty = ExecutionId::new(); + backend + .open_execution(empty, ExecutionKind::AgentTurn) + .await + .unwrap(); + + // (d) terminal (finalized), integrity row absent — not offending (can never resume again). + let terminal = ExecutionId::new(); + backend + .open_execution(terminal, ExecutionKind::AgentTurn) + .await + .unwrap(); + backend + .append(step_result(terminal, 0, b"v0")) + .await + .unwrap(); + zeph_db::query(sql!( + "DELETE FROM durable_execution_integrity WHERE execution_id = ?" + )) + .bind(terminal.as_uuid().to_string()) + .execute(backend.pool()) + .await + .unwrap(); + backend + .finalize(terminal, ExecutionStatus::Completed) + .await + .unwrap(); + + let found = backend.find_unsealed_resumable_executions().await.unwrap(); + assert_eq!( + found, + vec![offending], + "only the truly offending execution must be returned" + ); + } + + #[tokio::test] + async fn hwm_post_seal_absent_row_with_zero_committed_results_is_ok() { + // A sealed backend with no committed StepResult at all (e.g. an execution that was + // opened but never produced a result) has nothing to smuggle — accepted even post-seal. + let backend = mem_backend(1_048_576) + .await + .with_hwm_key(0, [34u8; 32]) + .with_integrity_sealed(true); + let exec = ExecutionId::new(); + backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(); + + assert!( + backend + .open_execution(exec, ExecutionKind::AgentTurn) + .await + .unwrap(), + "zero committed results, post-seal, must not be treated as tamper" + ); + } + #[tokio::test] async fn hwm_ignores_effect_intent_and_control_entries() { // Only `StepResult` rows count toward `committed_result_count` (S-new-1) — an EffectIntent diff --git a/crates/zeph-session/src/log.rs b/crates/zeph-session/src/log.rs index 4fef31f00..d4fa3cef4 100644 --- a/crates/zeph-session/src/log.rs +++ b/crates/zeph-session/src/log.rs @@ -35,6 +35,7 @@ use std::sync::{Arc, RwLock as StdRwLock}; use tokio::fs::{self, File, OpenOptions}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Mutex; +use zeph_common::anchor::{Anchor, AnchorStore, AnchorSubsystem}; use zeph_common::hash_chain::{ ChainError, ChainHash, ChainKeyRing, ChainStreamVerifier, KeyResolution, chain_next, genesis, }; @@ -87,10 +88,35 @@ fn history_integrity() -> Option> { HISTORY_INTEGRITY.read().ok().and_then(|g| g.clone()) } +/// Process-wide vault-anchor store (issue #6449). See +/// `zeph_subagent::transcript::configure_anchor_store`'s identical registry for the full +/// rationale — this mirrors it exactly. `None` (the default) disables anchor writes/checks +/// entirely: sessions behave exactly as they did under #6453. +static ANCHOR_STORE: StdRwLock>> = StdRwLock::new(None); + +/// Configure (or disable, with `None`) the vault-anchor store for every [`SessionEventLog`] +/// operation in this process from this point forward. +pub fn configure_anchor_store(store: Option>) { + if let Ok(mut guard) = ANCHOR_STORE.write() { + *guard = store; + } +} + +fn anchor_store() -> Option> { + ANCHOR_STORE.read().ok().and_then(|g| g.clone()) +} + /// Chunk size for [`SessionEventLog::read_chunked`] (spec §6.2 step 3: "bounded buffer, ≤ 100 /// events in memory at once"). const REPLAY_CHUNK_SIZE: usize = 100; +/// Bound on the single async vault-anchor `get` performed at open time (issue #6449). A vault +/// stall must fail deterministically rather than hang an unattended caller (durable resume, +/// scheduler restore, ACP resume, fork pre-copy) — this timeout applies uniformly regardless of +/// caller, since `open`/`open_exclusive` cannot distinguish attended from unattended callers +/// itself. +const ANCHOR_GET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + /// Append-only JSONL log for one conversation-session's `events.jsonl`. /// /// # Examples @@ -116,6 +142,10 @@ struct SessionWriteState { /// lifetime (fresh chaining start on a legacy or empty log) or seeded from the log's /// existing chained tail at open time (M3). prev: Option, + /// Total on-disk event count (seeded from any pre-existing content at open time, + /// incremented on every successful append) — the `count` half of the vault anchor written + /// by [`SessionEventLog::finalize`] (issue #6449). + count: u64, } pub struct SessionEventLog { @@ -136,6 +166,9 @@ pub struct SessionEventLog { /// verification, not just the initial open, so the deliberate operator override applies /// for this handle's whole lifetime rather than just its construction. allow_unverified: bool, + /// Captured once at open time, like `ring` (issue #6449) — `None` if no anchor store is + /// configured, or none is on file for this session yet. + anchor: Option, #[allow(dead_code)] // held only for its Drop (releases the flock, if taken) lock: Option, } @@ -251,6 +284,29 @@ impl SessionEventLog { let events_path = session_dir.join(EVENTS_FILE_NAME); let ring = history_integrity(); let identity = file_identity(session_dir); + + // Resolve the vault anchor once, bounded by a timeout (issue #6449) so a vault stall + // fails deterministically rather than hanging an unattended caller (durable resume, + // scheduler restore, ACP resume, fork pre-copy — none of which can offer an interactive + // retry). + let anchor = match anchor_store() { + Some(store) => tokio::time::timeout( + ANCHOR_GET_TIMEOUT, + store.get(AnchorSubsystem::SessionLog, &identity), + ) + .await + .map_err(|_| { + SessionError::Integrity(format!( + "vault anchor lookup for session '{}' timed out after {:?} — failing \ + closed rather than opening unverified", + session_dir.display(), + ANCHOR_GET_TIMEOUT + )) + })? + .map_err(|e| SessionError::Integrity(format!("anchor lookup failed: {e}")))?, + None => None, + }; + // Only the exclusive-lock holder may physically repair a torn tail (see // `read_events`'s doc comment) — a lockless `open()` cannot prove the "torn" line // isn't a live writer's in-flight, not-yet-fsynced append. Chain verification (S1) @@ -266,6 +322,7 @@ impl SessionEventLog { ring.as_deref(), &identity, allow_unverified, + anchor.as_ref(), ) .await?; @@ -277,16 +334,19 @@ impl SessionEventLog { set_permissions(&events_path, 0o600).await?; let next_seq = max_seq.map_or(0, |seq| seq + 1); + let count = max_seq.map_or(0, |seq| seq + 1); Ok(Self { events_path, writer: Mutex::new(SessionWriteState { file, prev: chain_head, + count, }), next_seq: AtomicU64::new(next_seq), file_identity: identity, ring, allow_unverified, + anchor, lock, }) } @@ -363,10 +423,47 @@ impl SessionEventLog { if let Some(h) = new_head { state.prev = Some(h); } + state.count += 1; Ok(envelope) } + /// Finalize this handle: if a vault-anchor store is configured (issue #6449) and this + /// handle's lifetime saw at least one chained append, persist an [`Anchor`] recording the + /// current `(epoch, count, head)` — a *prefix commitment* as of this clean close, not a + /// guarantee against every possible future truncation (see the module docs' session prefix + /// residual note). + /// + /// Written **last**, after every append is durably fsynced, so a crash before this point + /// leaves the log present with no anchor, which is always benign (never a false tamper + /// signature). + /// + /// A no-op, not an error, when no anchor store is configured or this handle never chained. + /// + /// # Errors + /// + /// Returns [`SessionError::Integrity`] if the configured anchor store's `put` fails. Callers + /// should treat this as best-effort and log rather than fail the whole close/shutdown flow — + /// the session log itself is already safely written. + pub async fn finalize(&self) -> Result<(), SessionError> { + let Some(store) = anchor_store() else { + return Ok(()); + }; + let (head, count) = { + let state = self.writer.lock().await; + let Some(head) = state.prev else { + return Ok(()); + }; + (head, state.count) + }; + let epoch = self.ring.as_ref().map_or(0, |r| r.current_epoch()); + let anchor = Anchor::new(epoch, count, head); + store + .put(AnchorSubsystem::SessionLog, &self.file_identity, anchor) + .await + .map_err(|e| SessionError::Integrity(format!("anchor put failed: {e}"))) + } + /// Read and validate every event currently in the log, dropping a torn trailing line from /// the result (INV-SP-2). Only physically repairs the file if this handle was opened via /// [`Self::open_exclusive`] — see that method's doc comment. @@ -389,6 +486,7 @@ impl SessionEventLog { self.ring.as_deref(), &self.file_identity, self.allow_unverified, + self.anchor.as_ref(), ) .await?; Ok(events) @@ -433,6 +531,7 @@ impl SessionEventLog { self.ring.as_deref(), &self.file_identity, self.allow_unverified, + self.anchor.as_ref(), on_chunk, ) .await @@ -457,6 +556,15 @@ struct SessionChainTracker<'a> { /// simulate a missing key, or `--allow-unverified` would be indistinguishable from a /// plain hard failure). allow_unverified: bool, + /// Vault anchor for this session, if configured and present (issue #6449). Also bypassed + /// entirely when `allow_unverified` is set, consistent with that override treating the + /// whole session as best-effort-trusted. + anchor: Option<&'a Anchor>, + /// Total physical event count fed so far (including any legacy prefix) — used to locate the + /// entry at `anchor.count` and capture the chain head immediately after it. + physical_index: u64, + /// The chain head immediately after the `anchor.count`-th event was fed, if reached. + anchor_checkpoint_head: Option, } impl<'a> SessionChainTracker<'a> { @@ -465,6 +573,7 @@ impl<'a> SessionChainTracker<'a> { ring: Option<&'a ChainKeyRing>, file_identity: &'a [u8], allow_unverified: bool, + anchor: Option<&'a Anchor>, ) -> Self { Self { path, @@ -473,6 +582,9 @@ impl<'a> SessionChainTracker<'a> { verifier: None, chain_started: false, allow_unverified, + anchor, + physical_index: 0, + anchor_checkpoint_head: None, } } @@ -497,7 +609,10 @@ impl<'a> SessionChainTracker<'a> { self.path.display() ))) } else { - Ok(()) // legacy prefix, no-op + // legacy prefix, no-op — but still advance the physical index (issue #6449: + // the anchor's `count` is a total physical count including any legacy prefix). + self.physical_index += 1; + Ok(()) }; }; self.chain_started = true; @@ -532,12 +647,33 @@ impl<'a> SessionChainTracker<'a> { .as_mut() .expect("verifier initialized above") .verify_next(&content, &stored) - .map_err(|e| describe_chain_error(self.path, &e)) + .map_err(|e| describe_chain_error(self.path, &e))?; + + self.physical_index += 1; + if let Some(anchor) = self.anchor + && self.physical_index == anchor.count + { + self.anchor_checkpoint_head = + self.verifier.as_ref().and_then(ChainStreamVerifier::head); + } + Ok(()) } - /// Finalize: logs a re-keyed note if applicable and returns the verified head hash (`None` - /// if the log was pure legacy — chaining never started). - fn finish(self) -> Option { + /// Finalize: logs a re-keyed note if applicable, enforces the anchor decision table (issue + /// #6449), and returns the verified head hash (`None` if the log was pure legacy — chaining + /// never started). + /// + /// # Errors + /// + /// Returns [`SessionError::Integrity`] if an anchor is configured and: this log is + /// legacy-looking (no chain field ever fed) despite the anchor existing — a whole-strip + /// downgrade signature; the on-disk event count is below the anchor's recorded count + /// (truncation); or the chain head at the anchor's recorded count disagrees with the stored + /// anchor. A no-op when `allow_unverified` was set (mirrors [`Self::feed`]'s bypass). + fn finish(self) -> Result, SessionError> { + if self.allow_unverified { + return Ok(None); + } if let Some(KeyResolution::Rekeyed(epoch)) = self .verifier .as_ref() @@ -551,13 +687,81 @@ impl<'a> SessionChainTracker<'a> { } // Pure legacy (chaining never started) while a key IS configured is anomalous: every // legitimately-written log since this process started should carry a chain field. - // Auto-trusted per FR-006 (it's also indistinguishable from a full-strip downgrade - // attack without the vault anchor, #6449), but must be observable, not silent (security - // review B2 condition, NFR-005). + // Auto-trusted per FR-006 (unless an anchor proves otherwise, checked below), but must + // be observable, not silent (security review B2 condition, NFR-005). if !self.chain_started && self.ring.is_some() { warn_legacy_under_active_key_once(self.path); } - self.verifier.and_then(|v| v.head()) + + if let Some(anchor) = self.anchor { + if !self.chain_started { + // Legacy-looking (no chain field anywhere), but a vault anchor exists for this + // session's identity: a file-write-only attacker cannot delete a vault entry, so + // this can only mean every chain field was deliberately stripped. + tracing::error!( + audit_event = "history_integrity_tamper", + subsystem = "session_log", + reason = "whole_strip_legacy_with_anchor", + path = %self.path.display(), + anchored_count = anchor.count, + "TAMPER DETECTED: session log is legacy-looking but a vault anchor exists for \ + it (issue #6449)" + ); + return Err(SessionError::Integrity(format!( + "TAMPER DETECTED in session log '{}': log has no chain metadata \ + (legacy-looking) but a vault anchor exists for it (anchored at count={}) — \ + this log was previously chained and its chain fields have been stripped", + self.path.display(), + anchor.count + ))); + } + if self.physical_index < anchor.count { + tracing::error!( + audit_event = "history_integrity_tamper", + subsystem = "session_log", + reason = "truncated_below_anchor_count", + path = %self.path.display(), + on_disk_count = self.physical_index, + anchored_count = anchor.count, + "TAMPER DETECTED: session log truncated below its anchored count (issue #6449)" + ); + return Err(SessionError::Integrity(format!( + "TAMPER DETECTED in session log '{}': on-disk event count ({}) is below the \ + anchored count ({}) — the log was truncated after being anchored", + self.path.display(), + self.physical_index, + anchor.count + ))); + } + let anchor_head = anchor.head().map_err(|e| { + SessionError::Integrity(format!( + "session log '{}' anchor is malformed: {e}", + self.path.display() + )) + })?; + match self.anchor_checkpoint_head { + Some(h) if h == anchor_head => {} + _ => { + tracing::error!( + audit_event = "history_integrity_tamper", + subsystem = "session_log", + reason = "anchor_head_mismatch", + path = %self.path.display(), + anchored_count = anchor.count, + "TAMPER DETECTED: session log chain head at the anchored count does not \ + match the stored vault anchor (issue #6449)" + ); + return Err(SessionError::Integrity(format!( + "TAMPER DETECTED in session log '{}': chain head at the anchored count \ + ({}) does not match the stored vault anchor", + self.path.display(), + anchor.count + ))); + } + } + } + + Ok(self.verifier.and_then(|v| v.head())) } } @@ -747,6 +951,7 @@ async fn read_events( ring: Option<&ChainKeyRing>, file_identity: &[u8], allow_unverified: bool, + anchor: Option<&Anchor>, ) -> Result<(Vec, Option, Option), SessionError> { let Some(mut lines) = EventLineReader::open(path).await? else { return Ok((Vec::new(), None, None)); @@ -755,7 +960,7 @@ async fn read_events( let mut events = Vec::new(); let mut max_seq = None; let mut torn = false; - let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified); + let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor); loop { match lines.next_line().await? { @@ -781,7 +986,7 @@ async fn read_events( // S1: chain verification has already run above, per event, as it was parsed — any failure // already returned via `chain.feed`'s `?` before this point, so `finish_torn_tail`'s // physical repair below is only ever reached once the whole read is chain-verified clean. - let chain_head = chain.finish(); + let chain_head = chain.finish()?; finish_torn_tail(path, valid_len, repair, torn).await?; @@ -800,6 +1005,7 @@ async fn read_events_chunked( ring: Option<&ChainKeyRing>, file_identity: &[u8], allow_unverified: bool, + anchor: Option<&Anchor>, mut on_chunk: impl FnMut(Vec) -> ControlFlow<()>, ) -> Result<(), SessionError> { let Some(mut lines) = EventLineReader::open(path).await? else { @@ -809,7 +1015,7 @@ async fn read_events_chunked( let mut chunk = Vec::with_capacity(REPLAY_CHUNK_SIZE); let mut torn = false; let mut broke_early = false; - let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified); + let mut chain = SessionChainTracker::new(path, ring, file_identity, allow_unverified, anchor); loop { match lines.next_line().await? { @@ -846,7 +1052,7 @@ async fn read_events_chunked( let valid_len = lines.valid_len; drop(lines); - let _chain_head = chain.finish(); + let _chain_head = chain.finish()?; finish_torn_tail(path, valid_len, repair, torn).await?; @@ -971,6 +1177,9 @@ pub(crate) async fn set_permissions(_path: &Path, _mode: u32) -> Result<(), Sess #[cfg(test)] mod tests { + use std::future::Future; + use std::pin::Pin; + use super::*; #[tokio::test] @@ -1341,14 +1550,14 @@ mod tests { } let (whole_file_events, _, _) = - read_events(log.path(), false, None, b"test-session", false) + read_events(log.path(), false, None, b"test-session", false, None) .await .unwrap(); assert_eq!(whole_file_events.len(), usize::try_from(N).unwrap()); let mut chunked_events = Vec::new(); let mut chunk_sizes = Vec::new(); - read_events_chunked(log.path(), false, None, b"test-session", false, |chunk| { + read_events_chunked(log.path(), false, None, b"test-session", false, None, |chunk| { assert!( chunk.len() <= REPLAY_CHUNK_SIZE, "a single chunk must never exceed REPLAY_CHUNK_SIZE ({REPLAY_CHUNK_SIZE}), got {}", @@ -1838,4 +2047,282 @@ mod tests { configure_history_integrity(None); } + + // --- Vault-anchor downgrade-resistance tests (issue #6449) --- + + /// In-memory [`AnchorStore`] mock for tests, mirroring the identical mock in + /// `zeph_subagent::transcript`'s test module. + #[derive(Default)] + struct MockAnchorStore { + map: std::sync::Mutex>, + } + + impl AnchorStore for MockAnchorStore { + fn get( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Pin< + Box< + dyn Future, zeph_common::anchor::AnchorError>> + + Send + + '_, + >, + > { + let result = self.get_sync(subsystem, file_id); + Box::pin(async move { result }) + } + + fn get_sync( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Result, zeph_common::anchor::AnchorError> { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + Ok(self.map.lock().unwrap().get(&key).cloned()) + } + + fn put( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + anchor: Anchor, + ) -> Pin> + Send + '_>> + { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + self.map.lock().unwrap().insert(key, anchor); + Box::pin(async { Ok(()) }) + } + + fn delete( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Pin> + Send + '_>> + { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + self.map.lock().unwrap().remove(&key); + Box::pin(async { Ok(()) }) + } + } + + /// FINDING B regression: a session log chained before any anchor store existed must still + /// open normally once one comes online — an absent anchor is never a tamper signature. + #[tokio::test] + async fn pre_anchor_chained_log_still_opens_with_anchor_store_online() { + configure_history_integrity(Some(test_ring(0, 40))); + let dir = tempfile::tempdir().unwrap(); + let log = SessionEventLog::open(dir.path()).await.unwrap(); + log.append( + None, + None, + SessionEvent::UserMessage { + text: "pre-anchor".to_owned(), + image_refs: vec![], + }, + ) + .await + .unwrap(); + drop(log); + + configure_anchor_store(Some(Arc::new(MockAnchorStore::default()))); + let log = SessionEventLog::open(dir.path()).await.unwrap(); + let events = log.read_all().await.unwrap(); + assert_eq!( + events.len(), + 1, + "absent anchor must never brick a legacy-chained log" + ); + + configure_anchor_store(None); + configure_history_integrity(None); + } + + #[tokio::test] + async fn whole_strip_of_anchored_session_is_tamper() { + configure_history_integrity(Some(test_ring(0, 41))); + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + + let dir = tempfile::tempdir().unwrap(); + let log = SessionEventLog::open(dir.path()).await.unwrap(); + log.append( + None, + None, + SessionEvent::UserMessage { + text: "one".to_owned(), + image_refs: vec![], + }, + ) + .await + .unwrap(); + log.append( + None, + None, + SessionEvent::SessionEnded { reason: "x".into() }, + ) + .await + .unwrap(); + log.finalize().await.unwrap(); + drop(log); + + // Sanity: anchored and untouched, the log still opens. + assert!(SessionEventLog::open(dir.path()).await.is_ok()); + + let path = dir.path().join(EVENTS_FILE_NAME); + let raw = tokio::fs::read_to_string(&path).await.unwrap(); + let stripped: String = raw + .lines() + .map(|line| { + let mut value: serde_json::Value = serde_json::from_str(line).unwrap(); + value.as_object_mut().unwrap().remove("chain"); + value.to_string() + }) + .collect::>() + .join("\n") + + "\n"; + tokio::fs::write(&path, stripped).await.unwrap(); + + match SessionEventLog::open(dir.path()).await { + Err(SessionError::Integrity(m)) => { + assert!(m.contains("TAMPER") && m.contains("vault anchor"), "{m}"); + } + other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()), + } + + configure_anchor_store(None); + configure_history_integrity(None); + } + + #[tokio::test] + async fn truncation_below_anchored_session_count_is_tamper() { + configure_history_integrity(Some(test_ring(0, 42))); + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + + let dir = tempfile::tempdir().unwrap(); + let log = SessionEventLog::open(dir.path()).await.unwrap(); + log.append( + None, + None, + SessionEvent::UserMessage { + text: "one".to_owned(), + image_refs: vec![], + }, + ) + .await + .unwrap(); + log.append( + None, + None, + SessionEvent::SessionEnded { reason: "x".into() }, + ) + .await + .unwrap(); + log.finalize().await.unwrap(); + drop(log); + + let path = dir.path().join(EVENTS_FILE_NAME); + let raw = tokio::fs::read_to_string(&path).await.unwrap(); + let first_line = raw.lines().next().unwrap(); + tokio::fs::write(&path, format!("{first_line}\n")) + .await + .unwrap(); + + match SessionEventLog::open(dir.path()).await { + Err(SessionError::Integrity(m)) => { + assert!(m.contains("TAMPER") && m.contains("truncated"), "{m}"); + } + other => panic!("expected Integrity TAMPER error, got {}", other.is_ok()), + } + + configure_anchor_store(None); + configure_history_integrity(None); + } + + /// Legitimate post-close growth (on-disk count > anchor.count, prefix matches) must open OK + /// — the anchor is a prefix commitment, not an exact-count requirement, for sessions. + #[tokio::test] + async fn growth_after_anchor_with_matching_prefix_is_ok() { + configure_history_integrity(Some(test_ring(0, 43))); + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + + let dir = tempfile::tempdir().unwrap(); + let log = SessionEventLog::open(dir.path()).await.unwrap(); + log.append( + None, + None, + SessionEvent::UserMessage { + text: "one".to_owned(), + image_refs: vec![], + }, + ) + .await + .unwrap(); + log.finalize().await.unwrap(); + + // More appended after the anchor was written (no new finalize) — a legitimate + // still-open session continuing to grow. + log.append( + None, + None, + SessionEvent::SessionEnded { reason: "x".into() }, + ) + .await + .unwrap(); + drop(log); + + let log = SessionEventLog::open(dir.path()).await.unwrap(); + let events = log.read_all().await.unwrap(); + assert_eq!( + events.len(), + 2, + "post-anchor growth with a matching prefix must open OK" + ); + + configure_anchor_store(None); + configure_history_integrity(None); + } + + #[tokio::test] + async fn finalize_is_noop_without_anchor_store_or_without_chaining() { + configure_history_integrity(Some(test_ring(0, 44))); + let dir = tempfile::tempdir().unwrap(); + let log = SessionEventLog::open(dir.path()).await.unwrap(); + log.append( + None, + None, + SessionEvent::SessionEnded { reason: "x".into() }, + ) + .await + .unwrap(); + log.finalize().await.unwrap(); + configure_history_integrity(None); + + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + let dir2 = tempfile::tempdir().unwrap(); + let log2 = SessionEventLog::open(dir2.path()).await.unwrap(); + log2.append( + None, + None, + SessionEvent::SessionEnded { + reason: "legacy".into(), + }, + ) + .await + .unwrap(); + log2.finalize().await.unwrap(); + let identity = file_identity(dir2.path()); + assert!( + store + .get_sync(AnchorSubsystem::SessionLog, &identity) + .unwrap() + .is_none(), + "no anchor should be written for an unchained handle" + ); + + configure_anchor_store(None); + } } diff --git a/crates/zeph-subagent/src/agent_loop.rs b/crates/zeph-subagent/src/agent_loop.rs index 68ddc7b41..2faf8a459 100644 --- a/crates/zeph-subagent/src/agent_loop.rs +++ b/crates/zeph-subagent/src/agent_loop.rs @@ -946,6 +946,15 @@ pub(super) async fn run_agent_loop( started_at, ); + // Anchor the transcript (issue #6449): best-effort, logged rather than propagated — the + // transcript file itself is already durably written, and a failed anchor put only means this + // one file falls back to #6453-level chain-only protection, never data loss. + if let Some(writer) = transcript_writer + && let Err(e) = writer.finalize().await + { + tracing::warn!(error = %e, task_id = %loop_task_id, "transcript anchor finalize failed"); + } + Ok(last_result) } diff --git a/crates/zeph-subagent/src/transcript.rs b/crates/zeph-subagent/src/transcript.rs index fe9e3553b..8cd14c11b 100644 --- a/crates/zeph-subagent/src/transcript.rs +++ b/crates/zeph-subagent/src/transcript.rs @@ -18,8 +18,10 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock as StdRwLock}; use serde::{Deserialize, Serialize}; +use zeph_common::anchor::{Anchor, AnchorStore, AnchorSubsystem}; use zeph_common::hash_chain::{ - ChainHash, ChainKeyRing, KeyResolution, chain_next, genesis, verify_chained_prefix, + ChainHash, ChainKeyRing, KeyResolution, chain_next, genesis, + verify_chained_prefix_with_checkpoint, }; use zeph_llm::provider::{Message, MessagePart}; @@ -81,6 +83,25 @@ fn history_integrity() -> Option> { HISTORY_INTEGRITY.read().ok().and_then(|g| g.clone()) } +/// Process-wide vault-anchor store (issue #6449), configured once at bootstrap alongside +/// [`configure_history_integrity`]. `None` (the default) disables anchor writes/checks entirely — +/// transcripts behave exactly as they did under #6453 (chain-verified, but not +/// downgrade-resistant against a whole-file strip). +static ANCHOR_STORE: StdRwLock>> = StdRwLock::new(None); + +/// Configure (or disable, with `None`) the vault-anchor store for every [`TranscriptWriter`]/ +/// [`TranscriptReader`] operation in this process from this point forward. See +/// [`configure_history_integrity`]'s doc for the single-set-at-startup contract this mirrors. +pub fn configure_anchor_store(store: Option>) { + if let Ok(mut guard) = ANCHOR_STORE.write() { + *guard = store; + } +} + +fn anchor_store() -> Option> { + ANCHOR_STORE.read().ok().and_then(|g| g.clone()) +} + /// Derive a transcript file's chain identity from its path (the `task_id`, e.g. `"abc123"` from /// `"abc123.jsonl"`) — binds the chain to this one file so a whole-file substitution (swapping /// in another task's transcript) breaks at the genesis hash. @@ -205,6 +226,10 @@ struct TranscriptWriteState { /// lifetime (fresh chaining start on a legacy or empty file) or seeded from the file's /// existing chained tail at open time (M3, see [`TranscriptWriter::new`]). prev: Option, + /// Total on-disk entry count (seeded from any pre-existing content at open time, + /// incremented on every successful append) — the `count` half of the vault anchor written + /// by [`TranscriptWriter::finalize`] (issue #6449). + count: u64, } #[derive(Clone)] @@ -244,19 +269,27 @@ impl TranscriptWriter { let ring = history_integrity(); let identity = file_identity(path); - let prev = if path.exists() { + let (prev, count) = if path.exists() { let entries = parse_entries(path, false).map_err(|e| io::Error::other(e.to_string()))?; - let (_messages, head) = verify_and_extract_messages(path, entries, ring.as_deref()) - .map_err(|e| io::Error::other(e.to_string()))?; - head + let count = u64::try_from(entries.len()).unwrap_or(u64::MAX); + let anchor = match anchor_store() { + Some(store) => store + .get_sync(AnchorSubsystem::SubagentTranscript, &identity) + .map_err(|e| io::Error::other(format!("anchor lookup failed: {e}")))?, + None => None, + }; + let (_messages, head) = + verify_and_extract_messages(path, entries, ring.as_deref(), anchor.as_ref()) + .map_err(|e| io::Error::other(e.to_string()))?; + (head, count) } else { - None + (None, 0) }; let file = zeph_common::fs_secure::append_private(path)?; Ok(Self { - state: Arc::new(Mutex::new(TranscriptWriteState { file, prev })), + state: Arc::new(Mutex::new(TranscriptWriteState { file, prev, count })), file_identity: identity, ring, }) @@ -331,12 +364,53 @@ impl TranscriptWriter { if let Some(h) = new_head { guard.prev = Some(h); } + guard.count += 1; Ok(()) }) .await .map_err(|e| io::Error::other(format!("spawn_blocking panicked: {e}")))? } + /// Finalize this writer: if a vault-anchor store is configured (issue #6449) and this + /// writer's lifetime saw at least one chained append, persist an [`Anchor`] recording the + /// final `(epoch, count, head)` — written **last**, after every append is durably flushed, + /// so a crash before this point leaves the file present with no anchor, which is always + /// benign (never a false tamper signature — see the module-level anchor docs). + /// + /// A no-op, not an error, when no anchor store is configured or this writer never chained + /// (pure legacy for its whole lifetime): there is nothing to anchor. + /// + /// # Errors + /// + /// Returns `io::Error` if the configured anchor store's `put` fails (a store-level failure, + /// not an absent anchor). Callers should treat this as best-effort and log rather than fail + /// the whole collection flow — the transcript file itself is already safely written. + pub async fn finalize(self) -> io::Result<()> { + let Some(store) = anchor_store() else { + return Ok(()); + }; + let (head, count) = { + let guard = self + .state + .lock() + .map_err(|_| io::Error::other("transcript writer lock poisoned"))?; + let Some(head) = guard.prev else { + return Ok(()); + }; + (head, guard.count) + }; + let epoch = self.ring.as_ref().map_or(0, |r| r.current_epoch()); + let anchor = Anchor::new(epoch, count, head); + store + .put( + AnchorSubsystem::SubagentTranscript, + &self.file_identity, + anchor, + ) + .await + .map_err(|e| io::Error::other(format!("anchor put failed: {e}"))) + } + /// Write the meta sidecar file for an agent. /// /// # Errors @@ -435,7 +509,15 @@ impl TranscriptReader { let entries = parse_entries(path, strict)?; let ring = history_integrity(); - let (messages, _head) = verify_and_extract_messages(path, entries, ring.as_deref())?; + let identity = file_identity(path); + let anchor = match anchor_store() { + Some(store) => store + .get_sync(AnchorSubsystem::SubagentTranscript, &identity) + .map_err(|e| SubAgentError::Integrity(format!("anchor lookup failed: {e}")))?, + None => None, + }; + let (messages, _head) = + verify_and_extract_messages(path, entries, ring.as_deref(), anchor.as_ref())?; Ok(messages) } @@ -584,22 +666,48 @@ fn parse_entries(path: &Path, strict: bool) -> Result, SubA /// /// Returns [`SubAgentError::Integrity`] when: the file carries chain metadata but no /// history-integrity key ring is configured (`ring.is_none()`, NFR-004 — never silently treated -/// as legacy); a partial strip is detected; or [`verify_chained_prefix`] reports a definite -/// tamper ([`ChainError::Mismatch`]) or an unverifiable/possibly-re-keyed chain -/// ([`ChainError::Unverifiable`]). +/// as legacy); a partial strip is detected; [`verify_chained_prefix`] reports a definite tamper +/// ([`ChainError::Mismatch`]) or an unverifiable/possibly-re-keyed chain +/// ([`ChainError::Unverifiable`]); or `anchor` disagrees with the on-disk content (issue #6449 — +/// see the read-side decision table in the module-level anchor docs, `zeph_common::anchor`). +#[allow(clippy::too_many_lines)] fn verify_and_extract_messages( path: &Path, entries: Vec, ring: Option<&ChainKeyRing>, + anchor: Option<&Anchor>, ) -> Result<(Vec, Option), SubAgentError> { let Some(chain_start) = entries.iter().position(|e| e.chain.is_some()) else { - // Pure legacy file: no chain metadata anywhere. Auto-trusted per FR-006 — but if a key - // ring IS configured, every legitimately-written file since this process started should - // carry a chain field, so a chainless file under an active key is anomalous: either - // genuine pre-upgrade content, or the signature of a full-strip downgrade attack (issue - // #6449, the vault-anchor gap that would otherwise catch this). Not distinguishable from - // here, so this stays accepted (never a hard failure) — but it must be observable, not - // silent (security review B2 condition, NFR-005). + // Legacy-looking file (no chain field anywhere) + a vault anchor exists for this file's + // identity: this IS a tamper signature, unlike the "absent anchor" case below. An anchor + // can only exist if this file was previously finalized while chained — a file-write-only + // attacker cannot delete a vault entry, so a legacy-looking file with a live anchor means + // every `chain` field was deliberately stripped (the whole-strip downgrade attack #6449 + // closes). + if let Some(anchor) = anchor { + tracing::error!( + audit_event = "history_integrity_tamper", + subsystem = "subagent_transcript", + reason = "whole_strip_legacy_with_anchor", + path = %path.display(), + anchored_count = anchor.count, + "TAMPER DETECTED: transcript is legacy-looking but a vault anchor exists for it \ + (issue #6449)" + ); + return Err(SubAgentError::Integrity(format!( + "TAMPER DETECTED in transcript '{}': file has no chain metadata (legacy-looking) \ + but a vault anchor exists for it (anchored at count={}) — this file was \ + previously chained and its chain fields have been stripped", + path.display(), + anchor.count + ))); + } + // Pure legacy file: no chain metadata anywhere, and no anchor either. Auto-trusted per + // FR-006 — but if a key ring IS configured, every legitimately-written file since this + // process started should carry a chain field, so a chainless file under an active key is + // anomalous: either genuine pre-upgrade content, or (absent an anchor to prove otherwise) + // indistinguishable from one. Not a hard failure — but it must be observable, not silent + // (security review B2 condition, NFR-005). if ring.is_some() { warn_legacy_under_active_key_once(path); } @@ -643,8 +751,22 @@ fn verify_and_extract_messages( } let identity = file_identity(path); - let (head, resolution) = verify_chained_prefix(ring, CHAIN_DOMAIN, &identity, &chained) - .map_err(|e| describe_chain_error(path, &e))?; + let on_disk_count = u64::try_from(entries.len()).unwrap_or(u64::MAX); + // The anchor's `count` is a total on-disk count; the chained region starts at `chain_start`, + // so the checkpoint index within `chained` (already sliced from `chain_start`) is + // `count - chain_start - 1` (0-based, the position of the anchor's last entry). + let checkpoint_index = anchor.and_then(|a| { + a.count + .checked_sub(u64::try_from(chain_start).unwrap_or(u64::MAX) + 1) + }); + let (head, checkpoint_head, resolution) = verify_chained_prefix_with_checkpoint( + ring, + CHAIN_DOMAIN, + &identity, + &chained, + checkpoint_index.unwrap_or(u64::MAX), + ) + .map_err(|e| describe_chain_error(path, &e))?; if let KeyResolution::Rekeyed(epoch) = resolution { tracing::info!( @@ -654,6 +776,52 @@ fn verify_and_extract_messages( ); } + if let Some(anchor) = anchor { + if on_disk_count < anchor.count { + tracing::error!( + audit_event = "history_integrity_tamper", + subsystem = "subagent_transcript", + reason = "truncated_below_anchor_count", + path = %path.display(), + on_disk_count, + anchored_count = anchor.count, + "TAMPER DETECTED: transcript truncated below its anchored count (issue #6449)" + ); + return Err(SubAgentError::Integrity(format!( + "TAMPER DETECTED in transcript '{}': on-disk entry count ({on_disk_count}) is \ + below the anchored count ({}) — the file was truncated after being anchored", + path.display(), + anchor.count + ))); + } + let anchor_head = anchor.head().map_err(|e| { + SubAgentError::Integrity(format!( + "transcript '{}' anchor is malformed: {e}", + path.display() + )) + })?; + match checkpoint_head { + Some(h) if h == anchor_head => {} + _ => { + tracing::error!( + audit_event = "history_integrity_tamper", + subsystem = "subagent_transcript", + reason = "anchor_head_mismatch", + path = %path.display(), + anchored_count = anchor.count, + "TAMPER DETECTED: transcript chain head at the anchored count does not match \ + the stored vault anchor (issue #6449)" + ); + return Err(SubAgentError::Integrity(format!( + "TAMPER DETECTED in transcript '{}': chain head at the anchored count ({}) \ + does not match the stored vault anchor", + path.display(), + anchor.count + ))); + } + } + } + let messages = entries.into_iter().map(|e| e.message).collect(); Ok((messages, Some(head))) } @@ -682,11 +850,26 @@ fn describe_chain_error(path: &Path, err: &zeph_common::hash_chain::ChainError) } } -/// Delete the oldest `.jsonl` files in `dir` when the count exceeds `max_files`. +/// Delete the oldest `.jsonl` files in `dir` when the count exceeds `max_files`, plus each +/// deleted file's companion `.meta.json` sidecar. /// /// Files are sorted by modification time (oldest first). Returns the number of /// files deleted. /// +/// # Vault anchors (issue #6449) +/// +/// This function stays deliberately synchronous (it is called from 2+ sync/`spawn_blocking` +/// contexts outside this feature's ownership — see `crates/zeph-subagent/src/manager/collect.rs` +/// — and making it async would force those callers async too, an out-of-scope blast radius). +/// It therefore does **not** delete a swept file's vault anchor inline. This is safe, not merely +/// deferred-and-hoped: an anchor whose file no longer exists is an **orphan**, and an orphan +/// anchor is always benign on read (an anchor is only ever consulted when opening a file that +/// exists — see the module-level anchor docs, `zeph_common::anchor`) — it never produces a false +/// TAMPER verdict for anything. Orphans left behind by this sweep are reaped later by the +/// process-wide reconcile-and-cap sweep (`zeph-core`'s `anchor_store` module), which lists every +/// `ZEPH_HISTORY_ANCHOR_*` vault key and drops any whose file no longer exists on disk, bounding +/// vault growth exactly as it already does for the session-anchor LRU cap. +/// /// # Errors /// /// Returns `io::Error` if the directory cannot be read or a file cannot be deleted. @@ -1452,4 +1635,219 @@ mod tests { configure_history_integrity(None); } + + // --- Vault-anchor downgrade-resistance tests (issue #6449) --- + + /// In-memory [`AnchorStore`] mock for tests — a simple `Mutex` keyed by + /// [`zeph_common::anchor::anchor_key`], mirroring `zeph_vault::MockVaultProvider`'s role for + /// the history-key tests above. + #[derive(Default)] + struct MockAnchorStore { + map: std::sync::Mutex>, + } + + impl AnchorStore for MockAnchorStore { + fn get( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, zeph_common::anchor::AnchorError>, + > + Send + + '_, + >, + > { + let result = self.get_sync(subsystem, file_id); + Box::pin(async move { result }) + } + + fn get_sync( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> Result, zeph_common::anchor::AnchorError> { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + Ok(self.map.lock().unwrap().get(&key).cloned()) + } + + fn put( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + anchor: Anchor, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + self.map.lock().unwrap().insert(key, anchor); + Box::pin(async { Ok(()) }) + } + + fn delete( + &self, + subsystem: AnchorSubsystem, + file_id: &[u8], + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + let key = zeph_common::anchor::anchor_key(subsystem, file_id); + self.map.lock().unwrap().remove(&key); + Box::pin(async { Ok(()) }) + } + } + + /// Regression test for FINDING B / acceptance criterion 2: a pre-anchor chained file (no + /// anchor store configured when it was written) must still open normally when an anchor + /// store comes online later — an absent anchor is never a tamper signature. + #[tokio::test] + async fn pre_anchor_chained_file_still_opens_with_anchor_store_online() { + configure_history_integrity(Some(test_ring(0, 20))); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("abc.jsonl"); + + // Written with no anchor store configured (the #6453-only posture). + let writer = TranscriptWriter::new(&path).unwrap(); + writer + .append(0, &test_message(Role::User, "pre-anchor")) + .await + .unwrap(); + drop(writer); + + // Now an anchor store comes online, but this file was never anchored. + configure_anchor_store(Some(Arc::new(MockAnchorStore::default()))); + let messages = TranscriptReader::load(&path).unwrap(); + assert_eq!( + messages.len(), + 1, + "absent anchor must never brick a legacy-chained file" + ); + + configure_anchor_store(None); + configure_history_integrity(None); + } + + /// Acceptance criterion 1/3: whole-strip of an anchored transcript is TAMPER, and so is + /// truncation below the anchored count. + #[tokio::test] + async fn whole_strip_of_anchored_transcript_is_tamper() { + configure_history_integrity(Some(test_ring(0, 21))); + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("abc.jsonl"); + let writer = TranscriptWriter::new(&path).unwrap(); + writer + .append(0, &test_message(Role::User, "one")) + .await + .unwrap(); + writer + .append(1, &test_message(Role::Assistant, "two")) + .await + .unwrap(); + writer.finalize().await.unwrap(); + + // Sanity: with the anchor present and content untouched, the file still opens. + let messages = TranscriptReader::load(&path).unwrap(); + assert_eq!(messages.len(), 2); + + // Whole-strip: rewrite every line with its `chain` field removed, so the file looks + // pre-feature-legacy — the attack #6449 closes. + let raw = std::fs::read_to_string(&path).unwrap(); + let stripped: String = raw + .lines() + .map(|line| { + let mut value: serde_json::Value = serde_json::from_str(line).unwrap(); + value.as_object_mut().unwrap().remove("chain"); + value.to_string() + }) + .collect::>() + .join("\n") + + "\n"; + std::fs::write(&path, stripped).unwrap(); + + let err = TranscriptReader::load(&path).unwrap_err(); + assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("TAMPER") && m.contains("vault anchor")); + + configure_anchor_store(None); + configure_history_integrity(None); + } + + #[tokio::test] + async fn truncation_below_anchored_count_is_tamper() { + configure_history_integrity(Some(test_ring(0, 22))); + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("abc.jsonl"); + let writer = TranscriptWriter::new(&path).unwrap(); + writer + .append(0, &test_message(Role::User, "one")) + .await + .unwrap(); + writer + .append(1, &test_message(Role::Assistant, "two")) + .await + .unwrap(); + writer.finalize().await.unwrap(); + + // Truncate the file to just its first line — content still verifies as a valid (shorter) + // chain, but disagrees with the anchor's recorded count. + let raw = std::fs::read_to_string(&path).unwrap(); + let first_line = raw.lines().next().unwrap(); + std::fs::write(&path, format!("{first_line}\n")).unwrap(); + + let err = TranscriptReader::load(&path).unwrap_err(); + assert_matches!(err, SubAgentError::Integrity(ref m) if m.contains("TAMPER") && m.contains("truncated")); + + configure_anchor_store(None); + configure_history_integrity(None); + } + + #[tokio::test] + async fn finalize_is_noop_without_anchor_store_or_without_chaining() { + // No anchor store configured: finalize must succeed as a no-op. + configure_history_integrity(Some(test_ring(0, 23))); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("abc.jsonl"); + let writer = TranscriptWriter::new(&path).unwrap(); + writer + .append(0, &test_message(Role::User, "x")) + .await + .unwrap(); + writer.finalize().await.unwrap(); + configure_history_integrity(None); + + // Anchor store configured, but chaining disabled: finalize must still be a no-op (no + // chain head to anchor). + let store: Arc = Arc::new(MockAnchorStore::default()); + configure_anchor_store(Some(Arc::clone(&store))); + let path2 = dir.path().join("legacy.jsonl"); + let writer2 = TranscriptWriter::new(&path2).unwrap(); + writer2 + .append(0, &test_message(Role::User, "legacy")) + .await + .unwrap(); + writer2.finalize().await.unwrap(); + assert!( + store + .get_sync(AnchorSubsystem::SubagentTranscript, b"legacy") + .unwrap() + .is_none(), + "no anchor should be written for an unchained writer" + ); + + configure_anchor_store(None); + } } diff --git a/crates/zeph-tui/src/app/reducer.rs b/crates/zeph-tui/src/app/reducer.rs index 66ffc9603..b67ceff91 100644 --- a/crates/zeph-tui/src/app/reducer.rs +++ b/crates/zeph-tui/src/app/reducer.rs @@ -795,6 +795,21 @@ pub(crate) fn reduce(app: &mut App, action: Action) -> Vec { app.set_active_panel(Panel::Durable); return vec![]; } + TuiCommand::IntegrityStatusInfo => { + // Detailed per-session/per-execution verification is a CLI-only operation + // today (`zeph sessions verify`, `zeph durable seal-integrity`) — it opens + // its own vault/DB connections outside the running agent process, which the + // reducer's no-I/O invariant (INV-R2) forbids doing inline here. This entry + // is a discoverability pointer, not a live check; a follow-up can wire a real + // async effect + spinner if in-TUI verification is wanted. + app.push_system_message_pub( + "Transcript/session tamper-evidence (issues #6360/#6449): run `zeph \ + sessions verify` to check session chains/anchors, or `zeph doctor` for \ + this deployment's overall anchor/seal status." + .to_owned(), + ); + return vec![]; + } TuiCommand::Settings => { app.set_active_panel(Panel::Settings); return vec![]; diff --git a/crates/zeph-tui/src/command.rs b/crates/zeph-tui/src/command.rs index e884612d9..e272ab987 100644 --- a/crates/zeph-tui/src/command.rs +++ b/crates/zeph-tui/src/command.rs @@ -134,6 +134,8 @@ pub enum TuiCommand { Settings, // Ctrl+F in-transcript search overlay (#6023) TranscriptSearch, + // Vault-anchor / hash-chain integrity status (issue #6449) + IntegrityStatusInfo, // Worktree subsystem (#4679) WorktreeList, WorktreeClean, @@ -327,6 +329,13 @@ fn build_view_commands() -> Vec { shortcut: Some("Ctrl+F"), command: TuiCommand::TranscriptSearch, }, + CommandEntry { + id: "integrity:status", + label: "Integrity: transcript/session tamper-evidence status", + category: "view", + shortcut: None, + command: TuiCommand::IntegrityStatusInfo, + }, ] } @@ -1168,8 +1177,9 @@ mod tests { #[test] fn registry_has_correct_count() { - // +1 view:latency (#6059); +2 settings + search:transcript (#6024/#6023) - assert_eq!(command_registry().len(), 30); + // +1 view:latency (#6059); +2 settings + search:transcript (#6024/#6023); + // +1 integrity:status (#6449) + assert_eq!(command_registry().len(), 31); } #[test] diff --git a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap index 039ec0a0a..d92a50173 100644 --- a/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap +++ b/crates/zeph-tui/src/widgets/snapshots/zeph_tui__widgets__command_palette__tests__command_palette_rounded_border_snapshot.snap @@ -20,7 +20,7 @@ expression: output │durable Durable: show durable ex│ │settings Settings: browse provide│ │search:transcript Find in conversation (Ct│ + │integrity:status Integrity: transcript/se│ │session:new Start new conversation │ │session:history Browse session history [│ - │session:next Switch to next session (│ ╰──────────────────────────────────────────────╯ diff --git a/specs/081-transcript-integrity/spec.md b/specs/081-transcript-integrity/spec.md index 8ffce8e0e..bf03882cb 100644 --- a/specs/081-transcript-integrity/spec.md +++ b/specs/081-transcript-integrity/spec.md @@ -245,8 +245,45 @@ decisions for future readers of the permanent spec. write access but *not* vault access. Within that model, in-place edits, reordering, partial chain-strips, and key-epoch tampering are all detected and fail closed. A **fully-consistent whole-file/whole-execution strip** (delete every chain field, or delete the durable - `durable_execution_integrity` row entirely) is **not** resisted in this initial implementation - — it is indistinguishable from genuine pre-feature legacy content without an external anchor - outside filesystem-write reach. Closing this (a vault-stored per-file attestation on the JSONL - side, and a post-migration-window "missing row = tamper" cutover on the durable side) is - tracked as a P1 follow-up: **issue #6449**. + `durable_execution_integrity` row entirely) was **not** resisted in this initial + implementation — it was indistinguishable from genuine pre-feature legacy content without an + external anchor outside filesystem-write reach. **Closed by issue #6449** (below). + +### 11.1 Vault-anchor downgrade-resistance (issue #6449, closes the §11 whole-file/whole-row gap) + +- **JSONL side**: a per-file **vault anchor** (`zeph_common::anchor::Anchor`, `{version, epoch, + count, head, written_at}`) is written on finalize/close (`TranscriptWriter::finalize`, + `SessionEventLog::finalize`) and checked on read. An age vault entry can only be removed by an + attacker holding the age private key, so "legacy-looking file, but a live anchor for its + identity" is an unambiguous whole-strip signature. An **absent** anchor is never a tamper + signature — it cannot be attacker-induced without the age key, so it is trusted exactly like + pre-#6449 behavior (this is what avoids bricking every session/transcript created before this + feature). Session anchors are a prefix commitment as of the last clean close (documented + residual: an attacker can roll back at most one run's worth of unanchored tail appends); + transcripts have no such residual (finalize-once). +- **Durable side**: `zeph durable seal-integrity` writes a vault-presence marker + (`ZEPH_DURABLE_INTEGRITY_SEALED`) after confirming no resumable (`status='running'`) execution + has committed `StepResult`s without an integrity row (drain-before-seal). Once sealed, an + absent row on a keyed, non-grandfathered execution with ≥1 committed result is unconditional + tamper — no DB column sits on this boundary, closing the `created_at`-column defeat an earlier + design iteration of this fix had (a DB-write attacker could otherwise forge the column the + cutover compared against). `--grandfather ` records a vault-stored, permanent + per-execution opt-out for operators who cannot drain a legacy execution. +- **Growth bound**: session anchors are never deleted on `sessions delete` (the event log itself + survives that command), so a reconcile-and-cap sweep (`zeph-core::anchor_store`, startup + + hourly) reaps orphaned anchors and evicts the oldest session anchors past + `[integrity] max_session_anchors` (default 512) — ordered by the anchor-embedded `written_at` + field, never filesystem mtime (attacker-writable). Eviction degrades a session to chain-only + protection; it never bricks (an evicted session still opens per the "absent anchor" rule + above). +- **Residuals, accepted and documented**: (1) session prefix-rollback (bounded, at most one + run's unanchored tail); (2) a grandfathered `execution_id` is a *permanent* forge-able slot, + not a frozen snapshot — each is an explicit, bounded operator opt-out; (3) sessions aged out + past `max_session_anchors` fall back to chain-only (§11) protection; (4) the reconcile sweep's + orphan-reap step treats file/session-directory absence as sufficient grounds to remove an + anchor — an attacker who deletes the real file, waits out a sweep window (≤ 1h, or a restart), + and recreates a forged legacy-looking replacement under the same identity gets it trusted + (overlaps residual (via FR-006's no-backfill posture) with fabricating a brand-new legacy + session; requires a destructive precursor the threat model already grants file-write access + to). No reap grace-window or tombstone is implemented; a candidate hardening for a future PR + if this residual proves unacceptable in practice. diff --git a/src/cli.rs b/src/cli.rs index d493bf834..953259b02 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -840,6 +840,20 @@ pub(crate) enum DurableCommand { #[arg(long)] force: bool, }, + /// Seal this backend against pre-feature integrity-row absence (issue #6449), closing the + /// durable downgrade gap. Refuses while any resumable (`running`) execution has committed + /// `StepResult`s but no integrity row — drain those to a terminal status first, or pass + /// `--grandfather` to explicitly opt specific execution IDs out of the seal + SealIntegrity { + /// Comma-separated execution IDs (UUIDs) to grandfather past the seal despite still + /// being resumable. Each ID becomes a permanent, vault-recorded opt-out for that one + /// execution — prefer draining where practical (see the command's long help) + #[arg(long, value_delimiter = ',')] + grandfather: Vec, + /// Report the drain-precondition scan result without writing the seal to the vault + #[arg(long)] + dry_run: bool, + }, } /// Typed session status filter for the `agents fleet` sub-command. @@ -1194,6 +1208,11 @@ pub(crate) enum SessionsCommand { /// Session ID id: String, }, + /// Verify a session's hash chain and vault anchor (issue #6449), without resuming it + Verify { + /// Session ID (omit to verify every session) + id: Option, + }, /// Fork a session into a new, independent child session Fork { /// Session ID to fork from diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index f4ca0da80..1c5fad633 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -13,6 +13,7 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use zeph_core::redact::scrub_content; +use zeph_core::vault::AgeVaultProvider; /// Individual check outcome. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -244,6 +245,79 @@ fn check_config_parse(config_path: &Path) -> (CheckResult, Option CheckResult { + let start = Instant::now(); + match config.integrity.anchor { + zeph_config::AnchorMode::None => CheckResult::ok( + "integrity.anchor", + "anchor = \"none\" (explicit opt-out — chain-verified but not downgrade-resistant)", + elapsed_ms(start), + ), + zeph_config::AnchorMode::Vault => { + let dir = zeph_core::vault::default_vault_dir(); + if dir.join("vault-key.txt").exists() && dir.join("secrets.age").exists() { + CheckResult::ok( + "integrity.anchor", + format!( + "anchor = \"vault\" (max_session_anchors = {})", + config.integrity.max_session_anchors + ), + elapsed_ms(start), + ) + } else { + CheckResult::warn( + "integrity.anchor", + "anchor = \"vault\" but no age vault found at bootstrap — sessions/transcripts \ + are tamper-evident (issue #6360) but not downgrade-resistant until a vault \ + exists (run `zeph --init`)", + elapsed_ms(start), + ) + } + } + } +} + +/// Reports the durable integrity seal status (issue #6449): sealed/unsealed, and how many +/// resumable pre-feature executions remain (blocking `zeph durable seal-integrity`). +fn check_durable_integrity_seal(config: &zeph_core::config::Config) -> CheckResult { + let start = Instant::now(); + if !config.durable.enabled { + return CheckResult::ok( + "durable.integrity_seal", + "durable execution disabled", + elapsed_ms(start), + ); + } + let dir = zeph_core::vault::default_vault_dir(); + let Ok(provider) = AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age")) + else { + return CheckResult::warn( + "durable.integrity_seal", + "durable enabled but no age vault found — cannot resolve seal status", + elapsed_ms(start), + ); + }; + let (sealed, grandfather) = zeph_core::anchor_store::load_durable_integrity_seal(&provider); + if sealed { + CheckResult::ok( + "durable.integrity_seal", + format!("sealed ({} execution(s) grandfathered)", grandfather.len()), + elapsed_ms(start), + ) + } else { + CheckResult::warn( + "durable.integrity_seal", + "not sealed — an absent integrity row on a resumed keyed execution is still trusted \ + as legacy; run `zeph durable seal-integrity` once no pre-feature resumable \ + executions remain (issue #6449)", + elapsed_ms(start), + ) + } +} + fn check_vault_file_exists(vault_path: &str) -> CheckResult { let start = Instant::now(); let p = Path::new(vault_path); @@ -951,6 +1025,10 @@ pub(crate) async fn run_doctor( #[cfg(feature = "deep-link")] results.push(check_url_scheme()); + // 17. integrity.anchor / durable.seal (issue #6449) + results.push(check_integrity_anchor(&config)); + results.push(check_durable_integrity_seal(&config)); + let report = DoctorReport { elapsed_ms: elapsed_ms(total_start), results, diff --git a/src/commands/durable.rs b/src/commands/durable.rs index 3aeb55f7b..6bbb13ec4 100644 --- a/src/commands/durable.rs +++ b/src/commands/durable.rs @@ -343,6 +343,22 @@ pub(crate) fn load_write_hwm_key(config: &Config) -> anyhow::Result { Ok(HwmKeys { current, previous }) } +/// Resolve the vault-sealed durable integrity marker + grandfather set (issue #6449) to attach +/// on a durable *write* path, mirroring [`load_write_hwm_key`]'s vault-load shape. +/// +/// Returns `(false, {})` when the vault is unreachable: an unsealed backend is the safe default +/// (migration posture unchanged), never a hard-fail of bootstrap. +pub(crate) fn load_integrity_seal( + _config: &Config, +) -> (bool, std::collections::HashSet) { + let dir = zeph_core::vault::default_vault_dir(); + let Ok(provider) = AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age")) + else { + return (false, std::collections::HashSet::new()); + }; + zeph_core::anchor_store::load_durable_integrity_seal(&provider) +} + /// Resolve the AEAD payload cipher to attach on a durable *write* path when /// `config.durable.encrypt_payload` is enabled (INV-5). /// @@ -443,6 +459,11 @@ async fn open_backend(config: &Config, reveal: bool) -> anyhow::Result { + handle_seal_integrity(&config, &grandfather, dry_run).await?; + } + } + + Ok(()) +} + +/// Seal this backend against pre-feature integrity-row absence (issue #6449, `zeph durable +/// seal-integrity`). +/// +/// # Errors +/// +/// Returns an error if the config/backend cannot be opened, if `grandfather` contains a +/// malformed UUID, or if the drain-before-seal precondition scan finds resumable executions not +/// covered by `grandfather` (the refusal path — not an I/O error, just a non-zero exit via +/// `anyhow::bail!`). +async fn handle_seal_integrity( + config: &Config, + grandfather: &[String], + dry_run: bool, +) -> anyhow::Result<()> { + let Some(backend) = open_backend(config, false).await? else { + anyhow::bail!("no durable journal found; nothing to seal"); + }; + + let grandfather_ids: std::collections::HashSet = grandfather + .iter() + .map(|s| { + ExecutionId::parse_str(s.trim()) + .map_err(|e| anyhow::anyhow!("invalid --grandfather execution id {s:?}: {e}")) + }) + .collect::>()?; + + let offending = backend + .find_unsealed_resumable_executions() + .await + .map_err(|e| anyhow::anyhow!("drain-precondition scan failed: {e}"))?; + let still_blocking: Vec = offending + .into_iter() + .filter(|id| !grandfather_ids.contains(id)) + .collect(); + + if !still_blocking.is_empty() { + let ids: Vec = still_blocking + .iter() + .map(|id| id.as_uuid().to_string()) + .collect(); + anyhow::bail!( + "refusing to seal: {} resumable execution(s) have committed StepResults but no \ + integrity row:\n {}\n\ + Let them drain to a terminal status, or pass --grandfather to explicitly \ + opt them out (a permanent, documented per-execution downgrade-resistance waiver — \ + prefer draining where practical).", + ids.len(), + ids.join("\n ") + ); } + if dry_run { + println!( + "Drain precondition satisfied — {} execution(s) would be grandfathered. \ + Dry run: vault not modified.", + grandfather_ids.len() + ); + return Ok(()); + } + + let dir = zeph_core::vault::default_vault_dir(); + let key_path = dir.join("vault-key.txt"); + let vault_path = dir.join("secrets.age"); + if !key_path.exists() || !vault_path.exists() { + anyhow::bail!( + "no age vault found at {}; run `zeph --init` first", + dir.display() + ); + } + let mut provider = AgeVaultProvider::load(&key_path, &vault_path) + .map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?; + + if !grandfather_ids.is_empty() { + let existing = provider + .get(zeph_core::anchor_store::DURABLE_INTEGRITY_GRANDFATHER_KEY) + .unwrap_or(""); + let rendered = zeph_core::anchor_store::render_grandfather_set(existing, &grandfather_ids); + provider + .set_secret_mut( + zeph_core::anchor_store::DURABLE_INTEGRITY_GRANDFATHER_KEY.to_owned(), + rendered, + true, + ) + .map_err(|e| anyhow::anyhow!("failed to record grandfather set: {e}"))?; + } + + // Display-only value (never on the security boundary — presence of the key is what matters, + // per `check_high_water_mark`'s doc): Unix seconds at seal time, for `zeph doctor` output. + let sealed_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + provider + .set_secret_mut( + zeph_core::anchor_store::DURABLE_INTEGRITY_SEALED_KEY.to_owned(), + sealed_at, + true, + ) + .map_err(|e| anyhow::anyhow!("failed to record seal marker: {e}"))?; + provider + .save() + .map_err(|e| anyhow::anyhow!("failed to save vault: {e}"))?; + + println!( + "Durable backend sealed against pre-feature integrity-row absence (issue #6449).{}", + if grandfather_ids.is_empty() { + String::new() + } else { + format!(" {} execution(s) grandfathered.", grandfather_ids.len()) + } + ); Ok(()) } diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 8a6371fac..eb849b395 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -43,6 +43,7 @@ pub(crate) async fn handle_sessions_command( events, } => show_session(&session_store, &data_dir, &id, from, to, events).await, SessionsCommand::Delete { id } => delete_session(&store, &session_store, &id).await, + SessionsCommand::Verify { id } => verify_sessions(&session_store, &data_dir, id).await, SessionsCommand::Fork { id, at } => { fork_session_cli(&session_store, &data_dir, &id, at).await } @@ -205,6 +206,70 @@ async fn delete_session( Ok(()) } +/// `sessions verify [id]` (issue #6449) — verify one session's (or every session's) hash chain +/// and vault anchor without resuming it. Uses the lockless +/// [`zeph_session::SessionEventLog::open`] and [`zeph_session::SessionEventLog::read_all`] path, +/// which runs full chain and anchor verification as a side effect of reading (see the +/// module-level anchor docs, `zeph_common::anchor`) — never mutates the session (never +/// `open_exclusive`). +/// +/// Prints one `OK`/`TAMPER`/`FAIL` line per session and exits non-zero if any failed. +/// +/// # Errors +/// +/// Returns an error if the session list cannot be queried, or if one or more sessions fail +/// verification (the summary failure, not a lookup failure). +#[cfg(any(feature = "acp", feature = "session"))] +async fn verify_sessions( + session_store: &zeph_session::SessionStore, + data_dir: &std::path::Path, + id: Option, +) -> anyhow::Result<()> { + let ids: Vec = if let Some(id) = id { + vec![id] + } else { + let sessions = session_store + .list(&zeph_session::SessionFilter { + status: None, + limit: usize::MAX, + }) + .await + .map_err(|e| anyhow::anyhow!("failed to list sessions: {e}"))?; + sessions.into_iter().map(|s| s.session_id).collect() + }; + + if ids.is_empty() { + println!("No sessions found."); + return Ok(()); + } + + let mut failures = 0usize; + for id in &ids { + let session_path = zeph_session::session_dir(data_dir, id); + let outcome = match zeph_session::SessionEventLog::open(&session_path).await { + Ok(log) => log.read_all().await.map(|events| events.len()), + Err(e) => Err(e), + }; + match outcome { + Ok(count) => println!("OK {id} ({count} events)"), + Err(e @ zeph_session::SessionError::Integrity(_)) => { + failures += 1; + println!("TAMPER {id}: {e}"); + } + Err(e) => { + failures += 1; + println!("FAIL {id}: {e}"); + } + } + } + + if failures > 0 { + anyhow::bail!("{failures} of {} session(s) failed verification", ids.len()); + } + println!("All {} session(s) verified OK.", ids.len()); + Ok(()) +} + /// `sessions fork [--at ]` — spec-068 P2, #5343. #[cfg(any(feature = "acp", feature = "session"))] async fn fork_session_cli( diff --git a/src/init/mod.rs b/src/init/mod.rs index 1cc30541a..452713500 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -632,6 +632,7 @@ pub fn run(output: Option) -> anyhow::Result<()> { step_deployment_mode(&mut state)?; step_vault(&mut state)?; + step_integrity(&mut state)?; step_llm(&mut state)?; step_memory(&mut state)?; step_context_compression(&mut state)?; @@ -811,6 +812,61 @@ fn step_vault(state: &mut WizardState) -> anyhow::Result<()> { Ok(()) } +/// Offer to generate and store `ZEPH_HISTORY_KEY` (issue #6449), the root secret that activates +/// transcript/session hash-chain tamper-evidence (#6360) and vault-anchor downgrade-resistance +/// (#6449, `[integrity] anchor = "vault"`, the default). Without this secret, `anchor = "vault"` +/// stays inert — chained files verify their internal consistency but nothing detects a whole-file +/// strip. +fn step_integrity(state: &mut WizardState) -> anyhow::Result<()> { + println!("== Step 1b/10: Transcript/Session Tamper-Evidence ==\n"); + println!( + "Zeph can cryptographically anchor sub-agent transcripts and session logs against \ + tampering and downgrade attacks (issues #6360/#6449). This requires a \ + ZEPH_HISTORY_KEY secret in the vault.\n" + ); + + if state.vault_backend != "age" { + println!( + "Secrets backend is \"{}\", not \"age\" — history tamper-anchoring requires the age \ + vault and will stay inactive until you switch backends and provision \ + ZEPH_HISTORY_KEY manually.\n", + state.vault_backend + ); + return Ok(()); + } + + let generate = Confirm::new() + .with_prompt("Generate and store a ZEPH_HISTORY_KEY now? (recommended)") + .default(true) + .interact()?; + + if generate { + let dir = zeph_core::vault::default_vault_dir(); + if !dir.join("vault-key.txt").exists() { + zeph_core::vault::AgeVaultProvider::init_vault(&dir)?; + } + let mut provider = zeph_core::vault::AgeVaultProvider::load( + &dir.join("vault-key.txt"), + &dir.join("secrets.age"), + )?; + if provider.get("ZEPH_HISTORY_KEY").is_none() { + let key = zeph_core::history_integrity::generate_history_key_b64(); + provider.set_secret_mut("ZEPH_HISTORY_KEY".to_owned(), key, false)?; + provider.save()?; + println!("ZEPH_HISTORY_KEY generated and stored in the vault.\n"); + } else { + println!("ZEPH_HISTORY_KEY already present in the vault — left unchanged.\n"); + } + } else { + println!( + "Skipped. Default `[integrity] anchor = \"vault\"` will stay inactive (chain-only \ + #6453-level protection; `zeph doctor` will show a WARN) until ZEPH_HISTORY_KEY is \ + provisioned — generate one later with `zeph vault set ZEPH_HISTORY_KEY `.\n" + ); + } + Ok(()) +} + #[allow(clippy::too_many_lines)] pub(crate) fn build_config(state: &WizardState) -> Config { let mut config = Config::default(); diff --git a/src/runner.rs b/src/runner.rs index cb7524d71..47740ff83 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -3316,6 +3316,7 @@ pub(crate) async fn run(mut cli: Cli) -> anyhow::Result<()> { let cipher = crate::commands::durable::load_write_cipher(config)?; let hmac_keys = crate::commands::durable::load_write_hmac_key(config)?; let hwm_keys = crate::commands::durable::load_write_hwm_key(config)?; + let integrity_seal = crate::commands::durable::load_integrity_seal(config); agent.with_durable_orchestration( config.durable.clone(), durable_url, @@ -3324,6 +3325,7 @@ pub(crate) async fn run(mut cli: Cli) -> anyhow::Result<()> { hwm_keys.current.map(|s| (s.epoch, s.key)), hmac_keys.previous, hwm_keys.previous.map(|s| (s.epoch, s.key)), + integrity_seal, ) } else { agent @@ -3335,6 +3337,7 @@ pub(crate) async fn run(mut cli: Cli) -> anyhow::Result<()> { let cipher = crate::commands::durable::load_write_cipher(config)?; let hmac_keys = crate::commands::durable::load_write_hmac_key(config)?; let hwm_keys = crate::commands::durable::load_write_hwm_key(config)?; + let integrity_seal = crate::commands::durable::load_integrity_seal(config); agent.with_durable_agent_turns( config.durable.clone(), durable_url, @@ -3344,12 +3347,52 @@ pub(crate) async fn run(mut cli: Cli) -> anyhow::Result<()> { hwm_keys.current.map(|s| (s.epoch, s.key)), hmac_keys.previous, hwm_keys.previous.map(|s| (s.epoch, s.key)), + integrity_seal, ) } else { agent }; agent.with_durable_subagent(config.durable.enabled && config.durable.subagent) }; + + // Vault-anchor downgrade-resistance (issue #6449): install the concrete anchor store and + // spawn the reconcile-and-cap sweep whenever `anchor = "vault"` (the default) and the age + // vault is actually reachable. Degrades gracefully (never a hard failure) when the vault + // isn't available yet — sessions/transcripts stay chain-verified (#6453) but not + // downgrade-resistant, exactly mirroring `configure_history_integrity_from_default_vault`'s + // own degrade posture. + if config.integrity.anchor == zeph_config::AnchorMode::Vault { + if let Some(vault_arc) = app.age_vault_arc() { + let store: std::sync::Arc = + std::sync::Arc::new(zeph_core::anchor_store::AgeVaultAnchorStore::new( + std::sync::Arc::clone(vault_arc), + (*supervisor).clone(), + )); + zeph_core::anchor_store::install_anchor_store(Some(store)); + + let transcript_dir = config + .agents + .transcript_dir + .clone() + .unwrap_or_else(|| std::path::PathBuf::from(".zeph/subagents")); + let sessions_dir_for_sweep = std::path::PathBuf::from(&config.session.data_dir); + zeph_core::anchor_store::spawn_anchor_sweep( + &supervisor, + std::sync::Arc::clone(vault_arc), + transcript_dir, + sessions_dir_for_sweep, + config.integrity.max_session_anchors, + ); + } else { + tracing::warn!( + "history tamper-anchoring disabled: age vault unavailable at bootstrap — \ + sessions/transcripts remain tamper-evident (issue #6360) but are not \ + downgrade-resistant against a whole-file strip (issue #6449); set \ + `[integrity] anchor = \"none\"` to silence this warning if this is intentional" + ); + } + } + let agent = { let baseline = zeph_experiments::ConfigSnapshot::from_config(config); let agent = agent.with_experiment(config.experiments.clone(), baseline);