diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 31d29587d..2ba3fb10e 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -535,6 +535,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jcp-cite" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "jcp-grant", + "jcp-receipt", + "jcp-runtime", + "jcp-schema", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "jcp-cli" version = "0.1.0" diff --git a/jcp/jcp-rs/Cargo.toml b/jcp/jcp-rs/Cargo.toml index 65e13434a..fcb6f59b4 100644 --- a/jcp/jcp-rs/Cargo.toml +++ b/jcp/jcp-rs/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/jcp-trace", "crates/jcp-a2a", "crates/jcp-x402", + "crates/jcp-cite", "crates/jcp-policy", "crates/jcp-prompt", "crates/jcp-orchestrate", diff --git a/jcp/jcp-rs/crates/jcp-cite/Cargo.toml b/jcp/jcp-rs/crates/jcp-cite/Cargo.toml new file mode 100644 index 000000000..16832ec1b --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-cite/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "jcp-cite" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Retrieval provenance for JCP — a signed, content-addressed record of what an agent READ to produce an answer, not merely what it did. Each source is bound by content id (so a deleted or edited source makes the citation verifiably dangling rather than silently broken) and by the grant it was read under (so a reader learns which sources they are not cleared to open). The citation's content id is the retrieval frame's payload_hash, so the jcp/2 receipt commits it: the receipt proves the retrieval was authorized and metered, the citation records what it consulted." + +[dependencies] +jcp-schema = { workspace = true } +jcp-grant = { workspace = true } +jcp-runtime = { workspace = true } +jcp-receipt = { workspace = true } +ed25519-dalek = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/jcp/jcp-rs/crates/jcp-cite/examples/cite.rs b/jcp/jcp-rs/crates/jcp-cite/examples/cite.rs new file mode 100644 index 000000000..f6d9ceb88 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-cite/examples/cite.rs @@ -0,0 +1,95 @@ +//! An agent answers a question, records what it READ, and seals a receipt that +//! commits the provenance. Then a reviewer checks the three things a rendered +//! citation link cannot tell them: is the answer unedited, are the sources +//! intact, and which sources may this reviewer not open. +//! +//! ```text +//! cargo run -p jcp-cite --example cite +//! ``` + +use ed25519_dalek::SigningKey; +use jcp_cite::{verify, Citation, Source}; +use jcp_grant::issue; +use jcp_receipt::{extract, seal}; +use jcp_runtime::Runtime; +use jcp_schema::{Capability, ContentId, Did, Energy, Grant}; + +fn main() { + let issuer = SigningKey::from_bytes(&[1u8; 32]); + let agent = SigningKey::from_bytes(&[2u8; 32]); + let agent_did = Did::from_verifying_key(&agent.verifying_key()); + let corpus = ContentId::of_bytes(b"acme-workspace"); + + // The agent holds two grants — one per sensitivity domain. + let g_finance = ContentId::of_bytes(b"grant:finance"); + let g_people = ContentId::of_bytes(b"grant:people"); + + // It reads two documents to answer, and records EACH with the content it saw + // and the grant it read under. + let doc_a = b"Q3 revenue was 4.2M, up 18% YoY."; + let doc_b = b"Headcount plan: 12 hires in Q4."; + let answer = b"Revenue grew 18%, and 12 hires are planned for Q4."; + + let citation = Citation::new( + agent_did, + ContentId::of_bytes(answer), + vec![ + Source::new(ContentId::of_bytes(doc_a), g_finance).at("/finance/q3-report"), + Source::new(ContentId::of_bytes(doc_b), g_people).at("/people/q4-plan"), + ], + 4_200, // µJ the retrieval consumed + [7; 16], + ); + println!("agent read {} sources → answer, {} µJ", citation.sources.len(), citation.energy_uj); + + // The retrieval is decided and metered under a real grant, and the receipt + // commits the citation by content id. + let grant = Grant { + issuer: Did::from_verifying_key(&issuer.verifying_key()), + subject: agent_did, + capability: Capability::new(corpus, ["retrieve"]), + budget_uj: 1_000_000, + budget_funds: None, + caveats: Vec::new(), + expiry: 0, + parent: None, + delegate_depth: None, + nonce: [0; 16], + }; + let sg = issue(&issuer, grant.clone()).unwrap(); + let frame = citation.frame(grant.id(), corpus).unwrap(); + let decision = Runtime::new().decide(&frame, &sg, agent_did, Energy::measured(citation.energy_uj), 1); + let steward = SigningKey::from_bytes(&[9u8; 32]); + let receipt = seal(&frame, &grant, &decision, &steward, "did:example:steward").unwrap(); + let core = extract(&receipt, "did:example:steward", &steward.verifying_key().to_bytes()).unwrap(); + + let sealed = citation.clone().seal(&agent).unwrap(); + println!( + "receipt commits the citation: {} ({} µJ on the receipt)\n", + core.payload_hash.as_deref() == Some(citation.id().unwrap().to_hex().as_str()), + core.cost_uj + ); + + // ---- a reviewer, holding only the receipt + the signed citation ---- + println!("── reviewer checks ──"); + println!(" citation signature valid: {}", verify(&sealed).is_ok()); + println!(" answer unedited: {}", sealed.citation.verify_answer(answer).is_ok()); + println!(" source 0 intact: {}", sealed.citation.verify_source(0, doc_a).is_ok()); + + // Now the finance doc is edited after the fact. A URL would still resolve — + // to the NEW text. The content-bound citation catches it. + let edited = b"Q3 revenue was 9.9M."; + println!( + " source 0 after an edit: {} ← dangling detected, not silently wrong", + sealed.citation.verify_source(0, edited).is_ok() + ); + + // A reviewer cleared only for finance is TOLD which sources they may not open. + let unreadable = sealed.citation.unreadable_by(&[g_finance]); + println!(" sources this reviewer may not open: {:?} (of {})", unreadable, sealed.citation.sources.len()); + for i in &unreadable { + if let Some(loc) = sealed.citation.sources[*i].locator.as_deref() { + println!(" → {loc} requires authority you do not hold"); + } + } +} diff --git a/jcp/jcp-rs/crates/jcp-cite/src/lib.rs b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs new file mode 100644 index 000000000..2b62bd4a6 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs @@ -0,0 +1,383 @@ +//! **Retrieval provenance** — a signed record of what an agent *read* to +//! produce an answer, not merely what it did. +//! +//! Agent platforms log actions. A workspace agent's audit trail will faithfully +//! record that it posted a report, filed a bug, updated a row — and record +//! nothing about the pages, rows and documents it consulted to decide any of it. +//! The answer therefore cannot be reconstructed, and a reviewer cannot tell +//! whether it was grounded in the corpus or invented. Citations, where they +//! exist at all, are convenience links: a URL in rendered output, unbound to the +//! answer, unsigned, and unaffected if the thing they point at changes or is +//! deleted. +//! +//! This crate closes three specific gaps, each as a checkable property rather +//! than an assurance: +//! +//! 1. **Sources read are recorded.** A [`Citation`] lists every source consulted, +//! in read order — the artifact an actions-only log cannot produce. +//! 2. **A citation cannot outlive its referent.** Each source is bound by +//! **content id**, not by locator. Delete or edit the source and +//! [`Citation::verify_source`] fails: the citation becomes *verifiably +//! dangling* instead of silently pointing at something that changed underneath +//! it. A URL cannot do this; a hash can. +//! 3. **A citation says who may read it.** Each source records the **grant it was +//! read under**, so [`Citation::unreadable_by_with`] tells a reader exactly +//! which sources they are not cleared to open — an authorization decision the +//! caller supplies (a true capability-subset check against the reader's held +//! authority), rather than handing them a link that 404s or, worse, one that +//! works when it should not. [`Citation::unreadable_by`] is the conservative +//! exact-grant shorthand. +//! +//! And the energy: a retrieval costs joules, and the citation carries them, so +//! the cost of grounding an answer is accounted like any other work. +//! +//! **How it composes.** A citation is content-addressed, and its id is the +//! retrieval frame's `payload_hash` — so the JCR-1 receipt (`jcp/2`, SPEC §8) +//! *commits* it. The receipt proves the retrieval was authorized, gated and +//! metered; the citation records what it consulted; the receipt binds the two. +//! Neither artifact is trusted on its own: +//! +//! ```text +//! verify the receipt → the retrieval was authorized + metered +//! citation.id() == payload_hash → this citation is the one that was sealed +//! verify_answer(bytes) → the answer has not been edited since +//! verify_source(i, bytes) → source i is intact, not deleted or rewritten +//! unreadable_by(grants) → which sources this reader may not open +//! ``` +//! +//! Ingest, not compete: the surfaces already cite. What they do not do is make a +//! citation *checkable*. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier}; +use jcp_schema::{canonical_cbor, ContentId, Did, Frame, FrameKind, SchemaError, Trust}; +use serde::{Deserialize, Serialize}; + +/// Errors building or verifying a citation. +#[derive(Debug, thiserror::Error)] +pub enum CiteError { + #[error("signature did not verify")] + BadSignature, + #[error("the citation's signer does not match the claimed reader")] + SignerMismatch, + #[error("malformed signature bytes: {0}")] + SigShape(String), + #[error("no such source index: {0}")] + NoSuchSource(usize), + /// The source's bytes no longer hash to what the citation recorded — it was + /// edited or replaced. A *dangling* citation, detected rather than missed. + #[error("source {0} does not match the cited content (edited or replaced)")] + SourceMismatch(usize), + /// The answer's bytes no longer hash to what the citation recorded. + #[error("the answer does not match the cited content (edited since)")] + AnswerMismatch, + #[error(transparent)] + Schema(#[from] SchemaError), +} + +/// One source consulted while producing an answer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Source { + /// Content id of the source **as it was read**. This is the binding: a + /// locator can be reused, redirected or deleted, a hash cannot. + pub content: ContentId, + /// The grant this source was read under. A verifier checks the read was + /// authorized; a reader checks whether they hold equivalent authority. + pub grant_id: ContentId, + /// Human-facing locator (URI, page id, row id) — **convenience only**, never + /// the binding. Omitted when the source is not addressable by name. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub locator: Option, +} + +impl Source { + pub fn new(content: ContentId, grant_id: ContentId) -> Self { + Source { content, grant_id, locator: None } + } + pub fn at(mut self, locator: impl Into) -> Self { + self.locator = Some(locator.into()); + self + } + /// Whether `bytes` are the source as cited. + pub fn matches(&self, bytes: &[u8]) -> bool { + ContentId::of_bytes(bytes) == self.content + } +} + +/// The signed provenance of one answer: what was read, under whose authority, +/// and at what energy cost. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Citation { + /// The principal that performed the retrieval and signs this record. + pub reader: Did, + /// Content id of the answer this provenance is for. + pub answer: ContentId, + /// Every source consulted, **in read order**. Order is part of the record: + /// it is what makes a retrieval replayable. + pub sources: Vec, + /// Joules the retrieval consumed, in µJ — grounding an answer is work, and + /// work is accounted. + pub energy_uj: u64, + /// Freshness/uniqueness so two identical retrievals are distinct records. + pub nonce: [u8; 16], +} + +impl Citation { + pub fn new(reader: Did, answer: ContentId, sources: Vec, energy_uj: u64, nonce: [u8; 16]) -> Self { + Citation { reader, answer, sources, energy_uj, nonce } + } + + /// The citation's content id — **this is what a retrieval frame's + /// `payload_hash` carries**, so the JCR-1 receipt commits this exact record. + pub fn id(&self) -> Result { + Ok(ContentId::of(self)?) + } + + /// Whether `bytes` are the answer as cited — detects an answer edited after + /// the fact while still carrying its original provenance. + pub fn verify_answer(&self, bytes: &[u8]) -> Result<(), CiteError> { + if ContentId::of_bytes(bytes) == self.answer { + Ok(()) + } else { + Err(CiteError::AnswerMismatch) + } + } + + /// Whether source `i` is intact. **This is the property a URL cannot have:** + /// if the source was deleted, edited, or replaced, this fails — the citation + /// is verifiably dangling rather than quietly wrong. + pub fn verify_source(&self, i: usize, bytes: &[u8]) -> Result<(), CiteError> { + let s = self.sources.get(i).ok_or(CiteError::NoSuchSource(i))?; + if s.matches(bytes) { + Ok(()) + } else { + Err(CiteError::SourceMismatch(i)) + } + } + + /// The sources this reader may **not** open, by index, decided by an + /// `authorizes` predicate the caller supplies. + /// + /// This is the general form, and the correct one: whether a reader may open a + /// source is an *authorization* question, and only the caller — who can + /// resolve a `grant_id` to its capability and knows the reader's own held + /// authority — can answer it. A caller with the enforcement runtime passes a + /// predicate that does a true capability-subset check (a reader holding a + /// broader grant that covers the source's read is authorized); this crate + /// stays a pure provenance record and takes no dependency on the decision + /// engine. The predicate returns `true` when the reader **may** open `source`. + /// + /// An answer may legitimately be produced under authority the reader lacks — + /// an agent reads under its own grants, not its invoker's — so rather than + /// hand over a citation that silently fails (or silently succeeds when it + /// should not), this names the gap: a UI can say *"3 of 7 sources are outside + /// your access"* instead of rendering a broken link. + pub fn unreadable_by_with(&self, mut authorizes: impl FnMut(&Source) -> bool) -> Vec { + self.sources + .iter() + .enumerate() + .filter(|(_, s)| !authorizes(s)) + .map(|(i, _)| i) + .collect() + } + + /// The sources a reader may not open, by the **conservative** rule: a source + /// is readable only if the reader holds the *exact* grant it was read under. + /// + /// This never claims access it cannot prove, but it is coarser than real + /// authorization: a reader holding a **broader** grant that would in fact + /// cover the read is still flagged here, because this compares grant ids by + /// identity, not capabilities by subset. For a true check, use + /// [`unreadable_by_with`](Self::unreadable_by_with) with a predicate backed by + /// the enforcement runtime. + pub fn unreadable_by(&self, held: &[ContentId]) -> Vec { + self.unreadable_by_with(|s| held.contains(&s.grant_id)) + } + + /// Every distinct grant this retrieval exercised. + pub fn grants_exercised(&self) -> Vec { + let mut v: Vec = self.sources.iter().map(|s| s.grant_id).collect(); + v.sort(); + v.dedup(); + v + } + + /// Sign this citation as the reader. + pub fn seal(self, reader_sk: &SigningKey) -> Result { + if self.reader != Did::from_verifying_key(&reader_sk.verifying_key()) { + return Err(CiteError::SignerMismatch); + } + let signature = reader_sk.sign(&canonical_cbor(&self)?).to_bytes().to_vec(); + Ok(SignedCitation { citation: self, signature }) + } + + /// The retrieval frame whose `payload_hash` is this citation — the frame a + /// runtime decides and a `jcp/2` receipt then commits. + pub fn frame(&self, grant_id: ContentId, resource: ContentId) -> Result { + Ok(Frame { + kind: FrameKind::ToolCall, + trust: Trust::Control, + grant_id, + action: "retrieve".into(), + resource, + payload_hash: self.id()?, + }) + } +} + +/// A citation signed by the principal that performed the retrieval. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedCitation { + pub citation: Citation, + pub signature: Vec, +} + +/// Verify a citation's signature against the reader it names. A third party +/// trusts the reader's key, not the surface that rendered the answer. +pub fn verify(sc: &SignedCitation) -> Result<(), CiteError> { + let vk = sc.citation.reader.verifying_key()?; + let sig = Signature::from_slice(&sc.signature).map_err(|e| CiteError::SigShape(e.to_string()))?; + vk.verify(&canonical_cbor(&sc.citation)?, &sig).map_err(|_| CiteError::BadSignature) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sk(s: u8) -> SigningKey { + SigningKey::from_bytes(&[s; 32]) + } + fn did(k: &SigningKey) -> Did { + Did::from_verifying_key(&k.verifying_key()) + } + + const DOC_A: &[u8] = b"Q3 revenue was 4.2M, up 18% YoY."; + const DOC_B: &[u8] = b"Headcount plan: 12 hires in Q4."; + const ANSWER: &[u8] = b"Revenue grew 18% and 12 hires are planned."; + + fn cite() -> (Citation, ContentId, ContentId) { + let g1 = ContentId::of_bytes(b"grant:finance"); + let g2 = ContentId::of_bytes(b"grant:people"); + let c = Citation::new( + did(&sk(2)), + ContentId::of_bytes(ANSWER), + vec![ + Source::new(ContentId::of_bytes(DOC_A), g1).at("/finance/q3"), + Source::new(ContentId::of_bytes(DOC_B), g2).at("/people/plan"), + ], + 4_200, + [1; 16], + ); + (c, g1, g2) + } + + #[test] + fn seals_and_verifies_under_the_reader_key() { + let (c, _, _) = cite(); + let sc = c.seal(&sk(2)).unwrap(); + verify(&sc).unwrap(); + // A different signer cannot claim to be the reader. + assert!(matches!(cite().0.seal(&sk(9)), Err(CiteError::SignerMismatch))); + } + + #[test] + fn tampering_with_the_source_list_breaks_the_signature() { + let (c, g1, _) = cite(); + let mut sc = c.seal(&sk(2)).unwrap(); + // Quietly add a source the retrieval never read. + sc.citation.sources.push(Source::new(ContentId::of_bytes(b"fabricated"), g1)); + assert!(matches!(verify(&sc), Err(CiteError::BadSignature))); + } + + /// THE property a URL cannot have: the cited source is edited or deleted, and + /// the citation reports it instead of pointing at whatever is there now. + #[test] + fn an_edited_source_makes_the_citation_verifiably_dangling() { + let (c, _, _) = cite(); + c.verify_source(0, DOC_A).expect("intact source verifies"); + // The page is edited after the answer was produced. + let err = c.verify_source(0, b"Q3 revenue was 9.9M.").unwrap_err(); + assert!(matches!(err, CiteError::SourceMismatch(0))); + // …and the answer itself is likewise pinned. + c.verify_answer(ANSWER).unwrap(); + assert!(matches!(c.verify_answer(b"Revenue fell."), Err(CiteError::AnswerMismatch))); + } + + /// A reader is told which sources they may not open, rather than being handed + /// a link that fails — or one that works when it should not. + #[test] + fn names_the_sources_a_reader_may_not_open() { + let (c, g1, g2) = cite(); + assert_eq!(c.unreadable_by(&[g1, g2]), Vec::::new(), "full authority reads all"); + assert_eq!(c.unreadable_by(&[g1]), vec![1], "the people doc is out of reach"); + assert_eq!(c.unreadable_by(&[]), vec![0, 1], "no authority reads nothing"); + assert_eq!(c.grants_exercised().len(), 2); + } + + /// The tightening: a reader holding a BROADER grant that covers both reads is + /// authorized under a real subset check, even though it matches neither + /// source's exact grant id — the case the conservative shorthand gets wrong. + #[test] + fn a_predicate_authorizes_a_broader_grant_that_exact_match_rejects() { + let (c, g1, g2) = cite(); // sources read under g1 (finance) and g2 (people) + let g_admin = ContentId::of_bytes(b"grant:admin-covers-all"); + + // The admin grant equals neither source's grant id, so the shorthand is + // pessimistic and flags both sources unreadable. + assert_eq!(c.unreadable_by(&[g_admin]), vec![0, 1]); + + // But the caller knows g_admin's capability is a superset of both reads. + // The predicate is where that real authorization lives — here a stand-in + // for `held_capability.covers(source_read)` via the runtime. + let covered = [g1, g2]; + let authorizes = |s: &Source| covered.contains(&s.grant_id); // g_admin covers these + assert_eq!( + c.unreadable_by_with(authorizes), + Vec::::new(), + "a broader grant covers every source under a true subset check" + ); + } + + /// The composition that matters: the citation's id IS the retrieval frame's + /// payload_hash, so a jcp/2 receipt commits this exact provenance record. + #[test] + fn the_receipt_commits_the_citation() { + use jcp_receipt::{extract, seal}; + use jcp_runtime::Runtime; + use jcp_schema::{Capability, Energy, Grant}; + + let issuer = sk(1); + let reader = sk(2); + let resource = ContentId::of_bytes(b"corpus"); + let grant = Grant { + issuer: did(&issuer), + subject: did(&reader), + capability: Capability::new(resource, ["retrieve"]), + budget_uj: 1_000_000, + budget_funds: None, + caveats: Vec::new(), + expiry: 0, + parent: None, + delegate_depth: None, + nonce: [0; 16], + }; + let sg = jcp_grant::issue(&issuer, grant.clone()).unwrap(); + + let (c, _, _) = cite(); + let frame = c.frame(grant.id(), resource).unwrap(); + let mut rt = Runtime::new(); + let d = rt.decide(&frame, &sg, did(&reader), Energy::measured(c.energy_uj), 1); + assert!(d.is_allow()); + + let steward = sk(7); + let env = seal(&frame, &grant, &d, &steward, "did:example:steward").unwrap(); + let core = extract(&env, "did:example:steward", &steward.verifying_key().to_bytes()).unwrap(); + + // The receipt commits THIS citation, by content id. + assert_eq!(core.payload_hash.as_deref(), Some(c.id().unwrap().to_hex().as_str())); + // Any other citation would not match — provenance cannot be swapped. + let other = Citation::new(did(&sk(2)), ContentId::of_bytes(b"other"), vec![], 0, [9; 16]); + assert_ne!(core.payload_hash.as_deref(), Some(other.id().unwrap().to_hex().as_str())); + // …and the joules are on the receipt. + assert_eq!(core.cost_uj, 4_200); + } +}