Skip to content

Commit 49c2638

Browse files
Your Nameclaude
andcommitted
feat(core,server): truth-kernel hardening Wave 6 -- audit closure (P0-A/B/C, P1-A/B) + Assist/Strict mode profiles
Closes all 7 findings from the external audit plus the Assist/Strict follow-on: P0-A: compute_with_recorded_freshness no longer lets a stale recorded Reconciled snapshot override a live-detected Degraded drift; edit.rs spend-time check now re-derives freshness instead of trusting a bare snapshot_id match (new AUTHORITY_SNAPSHOT_DEGRADED_SINCE_MINT refusal). P0-B: closes the TOCTOU window between verify_live's identity check and the read that serves content -- SymbolResolution::Found now carries the exact bytes verify_live read, so source()/understand() slice from the verified bytes instead of re-reading the file a second time. P0-C: weakest_route_confidence and path().certain now fail closed (Unresolved/false) on unparseable confidence instead of failing open to the strongest tier; coreness.rs's intentionally opposite-direction fail-open documented in place so it isn't mis-flagged as the same bug. P1-A: search_hybrid's degraded fallback now reaches function-body content (merges chunk-body FTS instead of symbol-only); understand() gains a "weak" resolution_confidence tier for near-zero scores and a note on unrecognized `kind` values. P1-B: fixed 3 suggested_next responses with args that didn't deserialize against their own target tool's Params; indexing_status no longer double-counts a file skipped on the latest pass but successfully indexed on a prior one; include_tests=false now filters gap-chunk hits by path, not just symbol-kind hits. Assist/Strict mode profiles: EditMode (assist default / strict) ORs a protocol-level profile onto always_require_edit_context/ kernel_enforced_writes without touching elicit_hub_confirm (turning that on for a client that never declared elicitation support is a hard failure, not a no-op) or elicit_via_agent_relay (explicitly weaker by design). list_tools now serves mode-appropriate tool descriptions (softened "never skip"/"NEVER use native Read" language under assist, unchanged under strict) independently of the static, mode-agnostic toolsnap schema. Benchmark harness (item 3) investigated and blocked -- no ANTHROPIC_API_KEY in this environment, same as the prior B8 blocker; documented rather than scaffolded unverifiably. calm-core 1278/1278, calm-server 440/440, full workspace suite green, fmt/clippy clean. See docs/plans/2026-08-21-truth-kernel-hardening-wave6-audit-closure-plan.md for full verification notes and every mid-implementation correction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b193b66 commit 49c2638

16 files changed

Lines changed: 1339 additions & 102 deletions

File tree

crates/calm-core/src/authority/snapshot.rs

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,31 @@ impl EvidenceSnapshot {
199199
project_root: &Path,
200200
state_conn: &Connection,
201201
) -> rusqlite::Result<Self> {
202-
let mut snapshot = Self::compute(conn, project_root)?;
203-
if let Some(recorded) = Self::load(state_conn, &snapshot.snapshot_id)?
202+
// Wave 6 (audit follow-up, P0-A): inlines `compute`'s own drift
203+
// derivation instead of calling it, so this function can tell
204+
// WHETHER `live_mtime_drift` is what produced a `Degraded` result --
205+
// that distinction is the whole fix. `live_mtime_drift` closes a
206+
// real, currently-observed lag window (disk changed, DB not
207+
// reindexed yet); a recorded snapshot from BEFORE that change can
208+
// share the same `snapshot_id` (content-addressed over DB rows
209+
// only, which haven't moved) and must never be allowed to silently
210+
// overturn what this call just observed live. Without this
211+
// distinction, the block below would re-promote a live-drifted
212+
// `Degraded` straight back to a stale `Reconciled`, and
213+
// `mint_review_authority_for_edit_context` would mint an authority
214+
// on a false freshness guarantee that survives all the way to
215+
// `edit.rs`'s spend-time check.
216+
let catalog = InputCatalog::for_project(project_root);
217+
let drift = index_input_drift(conn, &catalog)?;
218+
let mut freshness_class = drift_to_freshness(drift);
219+
let live_drifted =
220+
freshness_class == FreshnessClass::Current && live_mtime_drift(conn, project_root)?;
221+
if live_drifted {
222+
freshness_class = FreshnessClass::Degraded;
223+
}
224+
let mut snapshot = Self::build(conn, project_root, freshness_class)?;
225+
if !live_drifted
226+
&& let Some(recorded) = Self::load(state_conn, &snapshot.snapshot_id)?
204227
&& recorded.freshness_class > snapshot.freshness_class
205228
{
206229
snapshot.freshness_class = recorded.freshness_class;
@@ -392,6 +415,26 @@ fn live_mtime_drift(conn: &Connection, project_root: &Path) -> rusqlite::Result<
392415
return Ok(true);
393416
}
394417
}
418+
// Wave 6 (audit follow-up, P0-A.3): a brand-new source file added to
419+
// disk since the last index has no `file_index` row at all, so the
420+
// loop above -- which only ever iterates EXISTING rows -- can't see it.
421+
// A live-tree-walk fix (compare `collect_source_files`'s live path set
422+
// against `file_index`) was implemented and then REVERTED after it
423+
// broke 7 existing tests, all with the same root cause: this
424+
// codebase's own test fixtures routinely `std::fs::write` a file and
425+
// insert directly into `symbols`/persist a reconciled InputCatalog
426+
// WITHOUT a matching `file_index` row (a fast test-setup shortcut, not
427+
// a bug in the tests). That revealed a real, unresolved semantic
428+
// question, not just a test-fixture inconvenience: `index_input_drift`
429+
// (and therefore `Current`/`Reconciled`) was designed to answer "does
430+
// the index match disk" for config/context fingerprints specifically,
431+
// not "has every matching file actually been read into `file_index`" --
432+
// conflating the two would also fire on the normal, transient,
433+
// non-adversarial case of a freshly-added file the watcher hasn't
434+
// debounced yet, at every risk tier, not just the ones that actually
435+
// need Reconciled-strength freshness. Left as a documented, deliberate
436+
// residual pending a real design decision (e.g. a distinct signal
437+
// rather than folding into this boolean), not silently dropped.
395438
Ok(false)
396439
}
397440

@@ -893,6 +936,80 @@ mod tests {
893936
assert_ne!(after_change.freshness_class, FreshnessClass::Reconciled);
894937
}
895938

939+
#[test]
940+
fn compute_with_recorded_freshness_does_not_revive_a_stale_reconciled_over_live_drift() {
941+
// Wave 6 (audit follow-up, P0-A): the actual dangerous scenario --
942+
// content is genuinely unchanged according to the DB
943+
// (source_catalog_digest/snapshot_id unchanged, since nothing has
944+
// been reindexed), but the file changed ON DISK in the meantime.
945+
// Before this fix, the recorded Reconciled row (same snapshot_id,
946+
// because that digest never saw the disk-only change) would
947+
// silently overwrite the live-derived Degraded right back to
948+
// Reconciled -- letting `mint_review_authority_for_edit_context`
949+
// mint an authority on a false freshness guarantee. The sibling
950+
// test above (`..._falls_back_to_live_drift_when_content_changed_
951+
// since`) does NOT cover this despite its name: it mutates
952+
// file_index directly, which flips snapshot_id and makes the
953+
// recorded-lookup miss entirely -- a different, already-safe path.
954+
use crate::indexer::refresh::{InputCatalog, persist_index_input_snapshot};
955+
let root = tmp_project();
956+
let file_path = root.path().join("a.rs");
957+
std::fs::write(&file_path, "fn a() {}").unwrap();
958+
let indexed_mtime = crate::indexer::pipeline::mtime_secs(&file_path);
959+
960+
let conn = Connection::open_in_memory().unwrap();
961+
init_db(&conn).unwrap();
962+
conn.execute(
963+
"INSERT INTO file_index (path, hash, last_indexed, mtime) \
964+
VALUES ('a.rs', 'h1', 0, ?1)",
965+
params![indexed_mtime],
966+
)
967+
.unwrap();
968+
persist_index_input_snapshot(&conn, &InputCatalog::for_project(root.path())).unwrap();
969+
970+
let state = state_conn();
971+
972+
// Record a real full reconciliation for this exact content (same
973+
// snapshot_id the DB currently reflects) -- the legitimate case
974+
// `compute_with_recorded_freshness` exists to serve.
975+
let reconciled =
976+
EvidenceSnapshot::compute_after_reconciliation(&conn, root.path()).unwrap();
977+
reconciled.persist(&state).unwrap();
978+
979+
// Sanity: right now (no live drift yet), the recorded Reconciled
980+
// DOES correctly apply -- this is the upgrade path that must keep
981+
// working after the fix.
982+
let still_fresh =
983+
EvidenceSnapshot::compute_with_recorded_freshness(&conn, root.path(), &state).unwrap();
984+
assert_eq!(still_fresh.freshness_class, FreshnessClass::Reconciled);
985+
986+
// Mutate the file ON DISK ONLY -- no reindex, so file_index (and
987+
// therefore snapshot_id) stays byte-for-byte identical to what was
988+
// just recorded as Reconciled above.
989+
std::thread::sleep(std::time::Duration::from_millis(20));
990+
std::fs::write(
991+
&file_path,
992+
"fn a() { /* changed on disk, not reindexed */ }",
993+
)
994+
.unwrap();
995+
996+
let after_live_change =
997+
EvidenceSnapshot::compute_with_recorded_freshness(&conn, root.path(), &state).unwrap();
998+
assert_eq!(
999+
after_live_change.snapshot_id, reconciled.snapshot_id,
1000+
"snapshot_id is content-addressed over file_index DB rows only -- a \
1001+
live-disk-only change must NOT flip it (this assertion proves the \
1002+
test is actually exercising the dangerous path, not the already-safe \
1003+
snapshot_id-changed path the sibling test above covers)"
1004+
);
1005+
assert_eq!(
1006+
after_live_change.freshness_class,
1007+
FreshnessClass::Degraded,
1008+
"a live-disk-only change must not be silently revived back to \
1009+
Reconciled by a stale recorded snapshot sharing the same snapshot_id"
1010+
);
1011+
}
1012+
8961013
#[test]
8971014
fn live_disk_mtime_drift_downgrades_current_to_degraded() {
8981015
use crate::indexer::refresh::{InputCatalog, persist_index_input_snapshot};

crates/calm-core/src/config.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,29 @@ pub struct HubThresholdConfig {
188188
pub coreness_pct: f64,
189189
}
190190

191+
/// Wave 6 audit closure ("Assist/Strict mode profiles"): `assist`
192+
/// (default, today's behavior, zero change) or `strict`. `strict` ORs a
193+
/// fixed profile onto the individual `EditConfig` booleans below rather
194+
/// than replacing them -- see `EditConfig::always_require_edit_context_effective`/
195+
/// `kernel_enforced_writes_effective` for exactly what it sets and why
196+
/// those two and not the others. Deliberately gated on the truth-kernel
197+
/// correctness work (P0-A/P0-B/P0-C) landing first: shipping a "strict"
198+
/// label on top of the pre-fix live-verification gaps would have been
199+
/// strict about ceremony, not about truth.
200+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
201+
#[serde(rename_all = "lowercase")]
202+
pub enum EditMode {
203+
#[default]
204+
Assist,
205+
Strict,
206+
}
207+
208+
impl EditMode {
209+
pub fn is_strict(self) -> bool {
210+
matches!(self, EditMode::Strict)
211+
}
212+
}
213+
191214
/// Phase B (`docs/plans/2026-07-13-phase-b-incremental-graph-update.md`)
192215
/// Edit-tool behavior flags (`[edit]` in `.calm/config.json`).
193216
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -245,6 +268,11 @@ pub struct EditConfig {
245268
/// (2026-08-19) after being shown this exact tradeoff in plain terms;
246269
/// keep it default-off for every other project.
247270
pub elicit_via_agent_relay: bool,
271+
/// Wave 6: see `EditMode`'s own doc comment. Read only through
272+
/// `always_require_edit_context_effective`/`kernel_enforced_writes_effective`
273+
/// below, never the raw fields above, at any new call site -- otherwise
274+
/// `mode = "strict"` silently has no effect.
275+
pub mode: EditMode,
248276
}
249277

250278
impl Default for EditConfig {
@@ -255,10 +283,45 @@ impl Default for EditConfig {
255283
always_require_edit_context: false,
256284
kernel_enforced_writes: false,
257285
elicit_via_agent_relay: false,
286+
mode: EditMode::Assist,
258287
}
259288
}
260289
}
261290

291+
impl EditConfig {
292+
/// `mode = "strict"` ORs this gate on regardless of the raw
293+
/// `always_require_edit_context` field (monotonic: strict can only
294+
/// ADD strictness, never remove one a project explicitly turned on).
295+
/// Safe to fold into `strict` because it is a protocol-level,
296+
/// client-agnostic gate (the tool's own JSON-RPC error response) --
297+
/// behaves identically for every MCP client, unlike `elicit_hub_confirm`
298+
/// (see below).
299+
pub fn always_require_edit_context_effective(&self) -> bool {
300+
self.always_require_edit_context || self.mode.is_strict()
301+
}
302+
303+
/// Same contract as `always_require_edit_context_effective`. Safe to
304+
/// fold into `strict` for the same reason: every platform without
305+
/// kernel-enforced containment already falls back to the same textual
306+
/// check `atomic_write` uses unconditionally, so turning this on never
307+
/// makes writes fail where they previously succeeded -- see the field's
308+
/// own doc comment above.
309+
///
310+
/// `elicit_hub_confirm` and `elicit_via_agent_relay` deliberately have
311+
/// no `_effective` counterpart and are NOT folded into `strict`:
312+
/// `elicit_hub_confirm` enabled for a client that never declared
313+
/// elicitation support is not a silent no-op -- `elicit_setup`
314+
/// (crates/calm-server/src/tools/edit.rs) turns it into a hard
315+
/// client-incapability error on every hub/high-risk edit, which would
316+
/// brick edits for exactly the non-interactive automation clients most
317+
/// likely to opt into `strict`. `elicit_via_agent_relay` is an
318+
/// explicitly WEAKER opt-out (see its own doc comment) -- the opposite
319+
/// of what `strict` means, so it must never be implied by it.
320+
pub fn kernel_enforced_writes_effective(&self) -> bool {
321+
self.kernel_enforced_writes || self.mode.is_strict()
322+
}
323+
}
324+
262325
/// `[verification]` in `.calm/config.json` -- WS-6 first slice (docs/plans/
263326
/// 2026-08-03-ws6-verification-pipeline-execution-plan.md). Gates whether
264327
/// `edit_lines`/`edit_symbol` route a transaction through
@@ -1517,6 +1580,88 @@ mod tests {
15171580
let _ = std::fs::remove_dir_all(&tmp);
15181581
}
15191582

1583+
#[test]
1584+
fn edit_mode_defaults_to_assist_with_effective_accessors_matching_raw_fields() {
1585+
// Absent [edit].mode = "assist" = today's behavior, unchanged: the
1586+
// effective accessors must read identically to the raw booleans
1587+
// when mode never fires.
1588+
let d = Config::default();
1589+
assert_eq!(d.edit.mode, EditMode::Assist);
1590+
assert!(!d.edit.always_require_edit_context_effective());
1591+
assert!(!d.edit.kernel_enforced_writes_effective());
1592+
}
1593+
1594+
#[test]
1595+
fn edit_mode_strict_ors_the_two_protocol_gates_without_flipping_raw_fields() {
1596+
let tmp =
1597+
std::env::temp_dir().join(format!("ci_cfg_edit_mode_strict_{}", std::process::id()));
1598+
let _ = std::fs::remove_dir_all(&tmp);
1599+
std::fs::create_dir_all(&tmp).unwrap();
1600+
std::fs::write(tmp.join("config.json"), r#"{"edit": {"mode": "strict"}}"#).unwrap();
1601+
let loaded = load_config(&tmp).unwrap();
1602+
1603+
assert_eq!(loaded.edit.mode, EditMode::Strict);
1604+
// Raw fields stay exactly as written (nothing overrides them) --
1605+
// only the effective accessors change.
1606+
assert!(!loaded.edit.always_require_edit_context);
1607+
assert!(!loaded.edit.kernel_enforced_writes);
1608+
assert!(loaded.edit.always_require_edit_context_effective());
1609+
assert!(loaded.edit.kernel_enforced_writes_effective());
1610+
1611+
let diff = diff_from_default(&loaded);
1612+
assert!(diff.contains(&"edit.mode".to_string()), "{diff:?}");
1613+
let _ = std::fs::remove_dir_all(&tmp);
1614+
}
1615+
1616+
#[test]
1617+
fn edit_mode_strict_does_not_imply_elicit_hub_confirm_or_agent_relay() {
1618+
// See EditConfig::kernel_enforced_writes_effective's doc comment:
1619+
// these two are deliberately excluded from the strict compiler.
1620+
let tmp = std::env::temp_dir().join(format!(
1621+
"ci_cfg_edit_mode_strict_elicit_{}",
1622+
std::process::id()
1623+
));
1624+
let _ = std::fs::remove_dir_all(&tmp);
1625+
std::fs::create_dir_all(&tmp).unwrap();
1626+
std::fs::write(tmp.join("config.json"), r#"{"edit": {"mode": "strict"}}"#).unwrap();
1627+
let loaded = load_config(&tmp).unwrap();
1628+
1629+
assert!(!loaded.edit.elicit_hub_confirm);
1630+
assert!(!loaded.edit.elicit_via_agent_relay);
1631+
let _ = std::fs::remove_dir_all(&tmp);
1632+
}
1633+
1634+
#[test]
1635+
fn edit_mode_rejects_unknown_string() {
1636+
let tmp = std::env::temp_dir().join(format!("ci_cfg_edit_mode_bad_{}", std::process::id()));
1637+
let _ = std::fs::remove_dir_all(&tmp);
1638+
std::fs::create_dir_all(&tmp).unwrap();
1639+
std::fs::write(tmp.join("config.json"), r#"{"edit": {"mode": "yolo"}}"#).unwrap();
1640+
assert!(load_config(&tmp).is_err());
1641+
let _ = std::fs::remove_dir_all(&tmp);
1642+
}
1643+
1644+
#[test]
1645+
fn edit_mode_strict_can_coexist_with_an_explicit_raw_field_already_true() {
1646+
// Monotonic: an explicit `true` the project already set stays true
1647+
// regardless of mode -- strict is an OR, never a reset.
1648+
let tmp = std::env::temp_dir().join(format!(
1649+
"ci_cfg_edit_mode_strict_explicit_{}",
1650+
std::process::id()
1651+
));
1652+
let _ = std::fs::remove_dir_all(&tmp);
1653+
std::fs::create_dir_all(&tmp).unwrap();
1654+
std::fs::write(
1655+
tmp.join("config.json"),
1656+
r#"{"edit": {"mode": "assist", "always_require_edit_context": true}}"#,
1657+
)
1658+
.unwrap();
1659+
let loaded = load_config(&tmp).unwrap();
1660+
assert_eq!(loaded.edit.mode, EditMode::Assist);
1661+
assert!(loaded.edit.always_require_edit_context_effective());
1662+
let _ = std::fs::remove_dir_all(&tmp);
1663+
}
1664+
15201665
#[test]
15211666
fn orientation_config_defaults_to_inject_and_parses_override() {
15221667
let d = Config::default();

crates/calm-core/src/graph/coreness.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ pub fn compute_coreness(conn: &Connection) -> rusqlite::Result<HashMap<String, i
4848
// Unrecognized confidence strings (should never happen — every
4949
// writer goes through `EdgeConfidence::as_str`) are treated as
5050
// confirmed rather than silently dropped from the stricter graph.
51+
//
52+
// Wave 6 (audit follow-up, P0-C, reviewed 2026-08-21): an external
53+
// audit flagged both `rank() > 0` (which counts `Textual`/`Inferred`
54+
// edges as "confirmed", not just `Formal`/`Resolved`) and this
55+
// `unwrap_or(true)` as fail-open. Both are DELIBERATE here and were
56+
// left unchanged after review — this is the "stricter" graph that
57+
// feeds `is_hub`/bridge-hub gating (see the module doc comment
58+
// above): failing an edge INTO "confirmed" is the conservative
59+
// direction for a hub-detection false-negative guard (better to
60+
// over-gate a symbol as a hub than silently miss one). This is the
61+
// opposite direction from `trace.rs::weakest_route_confidence`/
62+
// `path().certain`, which fail closed toward LESS confidence for
63+
// the same reason applied to a different consumer (a display/
64+
// certainty signal, not a safety gate) — see that file's own Wave 6
65+
// fix. Do not "fix" this one the same way without re-deriving why
66+
// it's inverted here.
5167
let is_confirmed = crate::types::EdgeConfidence::parse(&confidence)
5268
.map(|c| c.rank() > 0)
5369
.unwrap_or(true);

0 commit comments

Comments
 (0)