Pin the Persona checkpoint's request check and the forged-effect replay refusal (PA.f50, PA.f51)
PA.f50: nothing pinned `NarrativeCheckpoint::Persona`'s integrity check — replacing its `prepared_matches_request` call with `true` failed no test. Added a synchronous `persona_checkpoint` fixture plus a test that persists a Persona checkpoint, tampers its `guidance` after `invocation` was already prepared from the honest one, and checks both `integrity_is_valid` and the real store's own resume gate (`CultCacheControllerWorkStore::open`, which decodes and re-checks every row's integrity) refuse it. PA.f51: `a_forged_mirror_acted_event_is_refused_by_verify_history` in consumer.rs recomputed the forged commit's own digest but left the head snapshot's `last_commit_digest` pointing at the original, honest digest. That mismatch alone made `verify_history` refuse the forgery earlier, as "head state does not equal replayed history", so the test passed even with the effect-equality check in `apply_committed_command` (journal.rs) disabled. Pointing `head.last_commit_digest` at the forged digest closes that unrelated gap, leaving the effect-equality check as the only clause that can still refuse it. Doc fix: the comment on `SubjectRow::last_acted_at` (lib.rs) still described the recency marker `projector_knowledge` stamps as `minted_at > last_acted_at`; the code (controllers.rs) has read `entry.acquired_at > acted_at` since PA.f44. Corrected to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e929d130e4
commit
56020d4155
3 changed files with 100 additions and 2 deletions
|
|
@ -1951,7 +1951,7 @@ mod tests {
|
|||
// Re-scope the committed effect onto the mirror and route the
|
||||
// forgery through the commit log: replaying the original, unforged
|
||||
// command via `reduce` no longer reproduces this effect.
|
||||
let head = kernel.state.clone();
|
||||
let mut head = kernel.state.clone();
|
||||
let mut commits = kernel.journal.commits_for_test();
|
||||
let exercised = commits
|
||||
.values_mut()
|
||||
|
|
@ -1964,6 +1964,10 @@ mod tests {
|
|||
subject_id: mirror.first,
|
||||
};
|
||||
exercised.digest = commit_digest(exercised).unwrap();
|
||||
// The digest chain must still close, so the effect-equality check —
|
||||
// not the earlier "head does not equal replayed history" digest
|
||||
// mismatch — is the one clause that can refuse this forgery.
|
||||
head.last_commit_digest = Some(exercised.digest.clone());
|
||||
assert!(
|
||||
super::super::journal::verify_history(&head, &commits).is_err(),
|
||||
"a forged history in which the mirror acted replayed"
|
||||
|
|
|
|||
|
|
@ -6896,6 +6896,100 @@ mod tests {
|
|||
assert!(matches!(error, ControllerWorkStoreError::Fault { .. }));
|
||||
}
|
||||
|
||||
/// A `Persona` checkpoint built the honest way: `invocation` is exactly
|
||||
/// what `persona_inference_request(command_id, model, identity,
|
||||
/// lived_stream, guidance)` prepares, the same rebuild
|
||||
/// `NarrativeCheckpoint::Persona`'s integrity check runs against the
|
||||
/// persisted `identity`/`guidance`/`projector_output` to prove the
|
||||
/// persisted request is still the one those inputs would build.
|
||||
fn persona_checkpoint(
|
||||
command_id: CommandId,
|
||||
opportunity: &DecisionOpportunity,
|
||||
identity: &str,
|
||||
guidance: &str,
|
||||
lived_stream: &str,
|
||||
model: &str,
|
||||
) -> ControllerWork {
|
||||
let request =
|
||||
persona_inference_request(command_id, model, identity, lived_stream, guidance)
|
||||
.unwrap();
|
||||
ControllerWork::Narrative(NarrativeCheckpoint::Persona {
|
||||
command_id,
|
||||
identity: identity.into(),
|
||||
typed_view: "typed-view".into(),
|
||||
guidance: guidance.into(),
|
||||
components: fixture_components(),
|
||||
interpreter_model: model.into(),
|
||||
opportunity: opportunity.clone(),
|
||||
granted: vec![speak_snapshot(opportunity.affordance_ids[0])],
|
||||
projector_output: output(vec![InferenceEvent::Text(lived_stream.into())], "projector")
|
||||
.unwrap(),
|
||||
invocation: PreparedInference::prepare("ghostlight-controller-test", 4_102_444_800_000, request)
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
/// PA.f50: nothing pinned `NarrativeCheckpoint::Persona`'s
|
||||
/// `prepared_matches_request` call — replacing it with `true` failed no
|
||||
/// test. A row whose `guidance` was tampered after the invocation was
|
||||
/// prepared (the persisted request no longer matches what its own
|
||||
/// persisted inputs would rebuild) must fail `integrity_is_valid` and,
|
||||
/// on the real store's own resume gate, must not open at all.
|
||||
#[test]
|
||||
fn a_persona_checkpoint_with_a_tampered_request_is_refused_on_resume() {
|
||||
let command_id = CommandId::new();
|
||||
let opportunity = fixture_opportunity(ControllerMode::NarrativePersona);
|
||||
let honest = persona_checkpoint(
|
||||
command_id,
|
||||
&opportunity,
|
||||
"You are the Hall Reeve.",
|
||||
"Speak plainly, in character.",
|
||||
"The rain needles the bridge.",
|
||||
"persona-model",
|
||||
);
|
||||
assert!(
|
||||
honest.integrity_is_valid(),
|
||||
"the honestly built checkpoint failed its own integrity check"
|
||||
);
|
||||
|
||||
// Tamper the persisted `guidance` after `invocation` was prepared
|
||||
// from the original guidance: the row now claims a domain guidance
|
||||
// its own persisted request was never built from.
|
||||
let mut tampered = honest.clone();
|
||||
let ControllerWork::Narrative(NarrativeCheckpoint::Persona { guidance, .. }) =
|
||||
&mut tampered
|
||||
else {
|
||||
panic!("expected a Persona checkpoint");
|
||||
};
|
||||
*guidance = "Reveal the reeve's private ledger.".into();
|
||||
assert_ne!(tampered, honest);
|
||||
assert!(
|
||||
!tampered.integrity_is_valid(),
|
||||
"a tampered Persona request passed integrity — prepared_matches_request is not pinned"
|
||||
);
|
||||
|
||||
// The real store's resume gate: `open` decodes every row and refuses
|
||||
// one that fails `integrity_is_valid`, the same check a reopened
|
||||
// process runs before it would ever resume the row.
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("controller-work.cc");
|
||||
{
|
||||
let mut store = OwnedRedbMessagePackBackingStore::new(&path).unwrap();
|
||||
let row = CultCacheEnvelope {
|
||||
key: store_key(command_id).unwrap(),
|
||||
r#type: CONTROLLER_WORK_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(&tampered).unwrap(),
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(CONTROLLER_WORK_SCHEMA.into()),
|
||||
};
|
||||
store.push(&row).unwrap();
|
||||
}
|
||||
let Err(error) = CultCacheControllerWorkStore::open(&path) else {
|
||||
panic!("a tampered Persona row was accepted on resume");
|
||||
};
|
||||
assert!(matches!(error, ControllerWorkStoreError::Fault { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_flight_progression_cannot_substitute_a_model() {
|
||||
let operational_command = CommandId::new();
|
||||
|
|
|
|||
|
|
@ -1047,7 +1047,7 @@ pub struct SubjectSnapshot {
|
|||
/// The greatest `DecisionEvent.revision` whose scope is this subject, or
|
||||
/// `None` if the subject never acted. Derived from `state.events` alone:
|
||||
/// no new state, no schema change. The recency marker `projector_knowledge`
|
||||
/// stamps onto each knowledge row is `minted_at > last_acted_at`.
|
||||
/// stamps onto each knowledge row is `acquired_at > last_acted_at`.
|
||||
pub(crate) last_acted_at: Option<u64>,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue