fix(pncc): revalidate receipt binding evidence
Human-Responsibility: ICE-GL∞ / 冰朔 Persona-Author: ICE-P-ZY001 / 铸渊 Execution-Runtime: Codex desktop / DEV-20260810-014 Development-ID: DEV-20260810-014 Authorization-Scope: GH-PNCC persona runtime source and tests only; no UI, deployment, or execution limb Source-Anchor: user instruction to continue GH-PNCC from repository facts and verifiable receipts
This commit is contained in:
parent
e9ee0a8bb1
commit
21d120d7b5
8 changed files with 199 additions and 13 deletions
|
|
@ -2873,30 +2873,83 @@ fn recover_lifecycle_request_at(
|
|||
inspection.status
|
||||
));
|
||||
}
|
||||
let receipt_path =
|
||||
session_directory(runtime_root, &inspection.session_id)?.join("lifecycle-receipt.json");
|
||||
bind_persisted_lifecycle_receipt_at(runtime_root, input, &inspection)
|
||||
}
|
||||
|
||||
fn bind_persisted_lifecycle_receipt_at(
|
||||
runtime_root: &Path,
|
||||
input: &PersonaLifecycleRunInput,
|
||||
inspection: &PersonaLifecycleRequestInspectionReceipt,
|
||||
) -> Result<PersonaLifecycleCommandReceipt, String> {
|
||||
let (request_id, request_fingerprint, session_id, repository) =
|
||||
lifecycle_request_identity(input)?;
|
||||
if !inspection.safe_to_bind_receipt
|
||||
|| inspection.request_id != request_id
|
||||
|| inspection.request_fingerprint != request_fingerprint
|
||||
|| inspection.session_id != session_id
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_BINDING_CHANGED_REQUIRES_REINSPECTION".into());
|
||||
}
|
||||
let receipt_path = session_directory(runtime_root, &session_id)?.join("lifecycle-receipt.json");
|
||||
let persisted: PersistedPersonaLifecycleReceipt = serde_json::from_slice(
|
||||
&fs::read(&receipt_path)
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_READ_FAILED: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("PERSONA_LIFECYCLE_RECEIPT_INVALID: {error}"))?;
|
||||
let mut record = load_session_record(runtime_root, &inspection.session_id)?;
|
||||
if persisted.schema != "hololake.pncc-persisted-lifecycle-receipt/v1"
|
||||
|| persisted.request_id != request_id
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_ID_MISMATCH".into());
|
||||
}
|
||||
if persisted.request_fingerprint != request_fingerprint {
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_CONFLICT".into());
|
||||
}
|
||||
if hex_digest(&persisted_lifecycle_payload_bytes(&persisted)?)
|
||||
!= persisted.lifecycle_receipt_hash
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH".into());
|
||||
}
|
||||
let mut record = load_session_record(runtime_root, &session_id)?;
|
||||
let recorded_repository = Path::new(&record.repository_path)
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("PERSONA_REPOSITORY_UNAVAILABLE: {error}"))?;
|
||||
if recorded_repository != repository || record.persona_id != input.wake.expected_persona_id {
|
||||
return Err("PERSONA_LIFECYCLE_REQUEST_SESSION_IDENTITY_MISMATCH".into());
|
||||
}
|
||||
if !persisted_lifecycle_identity_matches(&persisted, &session_id, &record.persona_id) {
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_IDENTITY_MISMATCH".into());
|
||||
}
|
||||
if record.request_id.is_some()
|
||||
|| record.request_fingerprint.is_some()
|
||||
|| record.lifecycle_receipt_hash.is_some()
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_BINDING_CHANGED_REQUIRES_REINSPECTION".into());
|
||||
}
|
||||
record.request_id = Some(inspection.request_id.clone());
|
||||
record.request_fingerprint = Some(inspection.request_fingerprint.clone());
|
||||
let events = verify_event_journal(runtime_root, &record)?;
|
||||
let expected_state = if persisted.outcome == "FAILED" {
|
||||
"DORMANT_AFTER_FAILURE"
|
||||
} else {
|
||||
"DORMANT"
|
||||
};
|
||||
let (_, observed_head) = exact_repository(&repository)?;
|
||||
if record.state != expected_state
|
||||
|| events.last().map(|event| event.kind.as_str()) != Some("DORMANT")
|
||||
|| primary_lease_held_by_session(runtime_root, &record)?
|
||||
|| observed_head != record.git_head
|
||||
|| require_clean_repository(&repository).is_err()
|
||||
{
|
||||
return Err("PERSONA_LIFECYCLE_RECEIPT_BINDING_CHANGED_REQUIRES_REINSPECTION".into());
|
||||
}
|
||||
record.request_id = Some(request_id.clone());
|
||||
record.request_fingerprint = Some(request_fingerprint.clone());
|
||||
record.lifecycle_receipt_hash = Some(persisted.lifecycle_receipt_hash);
|
||||
write_session_record(runtime_root, &record)?;
|
||||
verified_lifecycle_replay(
|
||||
runtime_root,
|
||||
&inspection.request_id,
|
||||
&inspection.request_fingerprint,
|
||||
&inspection.session_id,
|
||||
Path::new(&record.repository_path),
|
||||
&request_id,
|
||||
&request_fingerprint,
|
||||
&session_id,
|
||||
&repository,
|
||||
)?
|
||||
.ok_or_else(|| "PERSONA_LIFECYCLE_RECEIPT_RECOVERY_LOST_SESSION".into())
|
||||
}
|
||||
|
|
@ -4000,6 +4053,92 @@ mod tests {
|
|||
assert!(!after.safe_to_bind_receipt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_bind_a_receipt_tampered_after_safe_inspection() {
|
||||
let repo = persona_repo();
|
||||
let runtime = tempfile::TempDir::new().unwrap();
|
||||
let input = lifecycle_fact_input(repo.path());
|
||||
run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input.clone(),
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
"2026-08-11T00:00:01.000Z",
|
||||
|runtime_root, input, timestamp| {
|
||||
run_fact_task_at(runtime_root, input, timestamp, |_, _| {
|
||||
Ok(r#"{"summary":"Recoverable receipt.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hdlp"]}],"limitations":[]}"#.into())
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (_, _, session_id, _) = lifecycle_request_identity(&input).unwrap();
|
||||
let mut record = load_session_record(runtime.path(), &session_id).unwrap();
|
||||
record.request_id = None;
|
||||
record.request_fingerprint = None;
|
||||
record.lifecycle_receipt_hash = None;
|
||||
write_session_record(runtime.path(), &record).unwrap();
|
||||
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
|
||||
assert!(inspection.safe_to_bind_receipt);
|
||||
|
||||
let receipt_path = session_directory(runtime.path(), &session_id)
|
||||
.unwrap()
|
||||
.join("lifecycle-receipt.json");
|
||||
let mut persisted: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
|
||||
persisted["lifecycle"]["personaId"] = serde_json::Value::String("TAMPERED".into());
|
||||
fs::write(
|
||||
&receipt_path,
|
||||
serde_json::to_vec_pretty(&persisted).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error =
|
||||
bind_persisted_lifecycle_receipt_at(runtime.path(), &input, &inspection).unwrap_err();
|
||||
assert!(error.contains("PERSONA_LIFECYCLE_RECEIPT_HASH_MISMATCH"));
|
||||
let record = load_session_record(runtime.path(), &session_id).unwrap();
|
||||
assert!(record.request_id.is_none());
|
||||
assert!(record.request_fingerprint.is_none());
|
||||
assert!(record.lifecycle_receipt_hash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_bind_after_the_repository_advances_from_safe_inspection() {
|
||||
let repo = persona_repo();
|
||||
let runtime = tempfile::TempDir::new().unwrap();
|
||||
let input = lifecycle_fact_input(repo.path());
|
||||
run_idempotent_lifecycle_at(
|
||||
runtime.path(),
|
||||
input.clone(),
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
"2026-08-11T00:00:01.000Z",
|
||||
|runtime_root, input, timestamp| {
|
||||
run_fact_task_at(runtime_root, input, timestamp, |_, _| {
|
||||
Ok(r#"{"summary":"Recoverable receipt.","facts":[{"statement":"The brain exists.","evidencePaths":["brain/CORE.hldp"]}],"limitations":[]}"#.into())
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (_, _, session_id, _) = lifecycle_request_identity(&input).unwrap();
|
||||
let mut record = load_session_record(runtime.path(), &session_id).unwrap();
|
||||
record.request_id = None;
|
||||
record.request_fingerprint = None;
|
||||
record.lifecycle_receipt_hash = None;
|
||||
write_session_record(runtime.path(), &record).unwrap();
|
||||
let inspection = inspect_lifecycle_request_at(runtime.path(), &input).unwrap();
|
||||
assert!(inspection.safe_to_bind_receipt);
|
||||
|
||||
fs::write(repo.path().join("advanced.txt"), "new head\n").unwrap();
|
||||
run_git(repo.path(), &["add", "advanced.txt"]);
|
||||
run_git(repo.path(), &["commit", "-m", "advance persona repository"]);
|
||||
|
||||
let error =
|
||||
bind_persisted_lifecycle_receipt_at(runtime.path(), &input, &inspection).unwrap_err();
|
||||
assert!(error.contains("PERSONA_LIFECYCLE_RECEIPT_BINDING_CHANGED_REQUIRES_REINSPECTION"));
|
||||
let record = load_session_record(runtime.path(), &session_id).unwrap();
|
||||
assert!(record.request_id.is_none());
|
||||
assert!(record.request_fingerprint.is_none());
|
||||
assert!(record.lifecycle_receipt_hash.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_fabricate_a_receipt_for_a_session_interrupted_before_receipt_persistence() {
|
||||
let repo = persona_repo();
|
||||
|
|
|
|||
Loading…
Reference in a new issue