diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 2ba3fb10e..e30feef88 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -598,12 +598,23 @@ dependencies = [ "nvml-wrapper", ] +[[package]] +name = "jcp-flow" +version = "0.1.0" +dependencies = [ + "blake3", + "jcp-schema", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "jcp-gateway" version = "0.1.0" dependencies = [ "ed25519-dalek", "jcp-bridge", + "jcp-flow", "jcp-grant", "jcp-receipt", "jcp-runtime", diff --git a/jcp/jcp-rs/Cargo.toml b/jcp/jcp-rs/Cargo.toml index a167ae519..c413f9104 100644 --- a/jcp/jcp-rs/Cargo.toml +++ b/jcp/jcp-rs/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/jcp-orchestrate", "crates/jcp-intercept", "crates/jcp-efficiency", + "crates/jcp-flow", ] [workspace.package] diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index 4b2f3dff1..8ae31915b 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -61,6 +61,53 @@ pub enum OAuthError { /// Key/crypto failure. #[error("crypto: {0}")] Crypto(String), + /// The authorization response carried no `iss` (RFC 9207). Treated as fatal: + /// a response that cannot identify its issuer is indistinguishable from the + /// mix-up case it exists to rule out. + #[error("authorization response has no iss parameter (RFC 9207)")] + AuthorizationIssuerMissing, + /// The authorization response's `iss` is not the server the request went to — + /// a mix-up attack, or a misconfiguration that must be treated as one. + #[error("authorization response issuer {got} is not the expected {expected}")] + AuthorizationIssuerMixUp { expected: String, got: String }, +} + +/// The RFC 9207 authorization-response parameter naming the issuing server. +pub const AUTHORIZATION_ISS_PARAM: &str = "iss"; + +/// **RFC 9207 issuer identification** — check that an authorization response came +/// from the authorization server the request was actually sent to. +/// +/// This is a distinct check from the `iss` *claim* inside an access token +/// ([`verify_access_token`]): that claim says who signed a token already in hand, +/// which is only meaningful once the token is trusted. RFC 9207 guards the step +/// before, where a client redeems an authorization code. In a **mix-up attack** a +/// client is steered to a malicious authorization server, which hands back a code +/// or token minted elsewhere; without an issuer on the *response*, the client +/// cannot tell that the answer came from a different server than the question, and +/// forwards the credential to the honest server. Comparing the response's `iss` +/// against the server the request went to is what breaks that. +/// +/// Fails closed on a missing `iss`: a response that cannot name its issuer proves +/// nothing about its origin, so it is refused rather than assumed benign. +/// +/// `response_iss` must be the **decoded** parameter value. It arrives in a redirect +/// URI query, where a `did:key:` issuer's colons are percent-encoded; comparing the +/// still-encoded form against a decoded issuer would fail every legitimate response +/// and pass none, so decoding is the caller's job and is not guessed at here. +pub fn check_authorization_issuer( + response_iss: Option<&str>, + expected_issuer: &Did, +) -> Result<(), OAuthError> { + let expected = did_key(expected_issuer); + match response_iss { + None => Err(OAuthError::AuthorizationIssuerMissing), + Some(got) if got == expected => Ok(()), + Some(got) => Err(OAuthError::AuthorizationIssuerMixUp { + expected, + got: got.to_string(), + }), + } } /// Project a verified [`SignedGrant`] into an RFC 9068 access-token JWT, signed @@ -280,6 +327,49 @@ mod tests { issue(sk, grant).unwrap() } + #[test] + fn authorization_issuer_matches_the_server_asked() { + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + assert!(check_authorization_issuer(Some(&did_key(&honest)), &honest).is_ok()); + } + + #[test] + fn authorization_issuer_catches_a_mix_up() { + // The client sent its request to `honest`, but the response names + // `attacker` — the credential was minted somewhere else and must not be + // forwarded to the honest server. + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + let attacker = Did::from_verifying_key(&SigningKey::from_bytes(&[3u8; 32]).verifying_key()); + let error = check_authorization_issuer(Some(&did_key(&attacker)), &honest) + .expect_err("a foreign issuer is rejected"); + match error { + OAuthError::AuthorizationIssuerMixUp { expected, got } => { + assert_eq!(expected, did_key(&honest)); + assert_eq!(got, did_key(&attacker)); + } + other => panic!("expected a mix-up error, got {other:?}"), + } + } + + #[test] + fn authorization_without_an_issuer_fails_closed() { + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + assert!(matches!( + check_authorization_issuer(None, &honest), + Err(OAuthError::AuthorizationIssuerMissing) + )); + } + + #[test] + fn a_still_encoded_issuer_does_not_pass() { + // Guards the documented contract: the caller decodes. A percent-encoded + // value must not be silently accepted, or the check would pass on a string + // that was never compared to the real issuer. + let honest = Did::from_verifying_key(&SigningKey::from_bytes(&[9u8; 32]).verifying_key()); + let encoded = did_key(&honest).replace(':', "%3A"); + assert!(check_authorization_issuer(Some(&encoded), &honest).is_err()); + } + #[test] fn base64url_roundtrips() { for case in [&b""[..], b"f", b"fo", b"foo", b"foob", b"any byte \x00\xff!"] { diff --git a/jcp/jcp-rs/crates/jcp-flow/Cargo.toml b/jcp/jcp-rs/crates/jcp-flow/Cargo.toml new file mode 100644 index 000000000..6e7aa8545 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jcp-flow" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Propagated provenance for JCP — the witness scan and session store that fold a derived value's flow Label from the tool outputs it was computed from, so authorization can be conditioned on where a value CAME FROM, not just its literal bytes. An exact blake3 window index detects verbatim carry across tool calls; each verdict emits a content-addressed, offline-checkable Witness." + +[dependencies] +jcp-schema = { workspace = true } +blake3 = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/jcp/jcp-rs/crates/jcp-flow/src/lib.rs b/jcp/jcp-rs/crates/jcp-flow/src/lib.rs new file mode 100644 index 000000000..c355ac11b --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/src/lib.rs @@ -0,0 +1,235 @@ +//! Propagated provenance for JCP — the witness scan and session store. +//! +//! The [flow lattice](jcp_schema::flow) says what a label *is* and how two labels +//! fold. This crate computes labels from real bytes: as a run reads tool outputs +//! and derives new values, the [`FlowStore`] tracks where each value's bytes came +//! from and folds a derived value's label from its ancestors. The result is that +//! authorization can be conditioned on **where a value came from**, not merely on +//! its literal bytes — the gap JCP's one-bit `Trust` tag cannot express. +//! +//! Two things make this a reference monitor rather than a heuristic: +//! +//! - The carry detector is an **exact** `blake3` window index. Two values share a +//! fingerprint exactly when they share a verbatim [`SCAN_W`]-byte run — no +//! rolling-hash state to get wrong, and the same bytes fingerprint identically +//! on every platform. +//! - Every verdict emits a self-contained [`Witness`]: the ancestor cut, the taint +//! edges, and the folded label, content-addressed. A receipt commits only its +//! `id()`; an auditor re-folds it offline to re-derive the verdict +//! ([`verify_witness`]). +//! +//! **Scope.** This crate is the propagation and its offline witness check. The +//! runtime *gate* (does a label pass a grant's ceiling), the `jcp/3` receipt seal, +//! and the gateway relay that drives them are later layers; see the FL-2 plan. +//! +//! **Honest residuals.** The scan sees verbatim carry, not meaning: base64, +//! translation, or paraphrase defeat it, and soundness then rests on a registered +//! sensitive term or the `HighWater` floor. Sub-`SCAN_W` unregistered secrets are +//! invisible to the scan. `verify_witness` checks *consistency* (the cut folds to +//! the sealed label), not *truth* against original bytes — that needs an opt-in +//! disclosure tier. The lattice laws prove taint never decreases and a verdict is +//! reproducible; none of them says prompt injection cannot succeed. + +pub mod store; +pub mod witness; + +pub use store::{ambient_source, windows, FlowStore, Mode, ScanParams, SCAN_W}; +pub use witness::{ + verify_witness, CutNode, Edge, EdgeKind, Witness, WitnessError, WITNESS_SCHEMA, +}; + +#[cfg(test)] +mod tests { + use super::*; + use jcp_schema::flow::{FlowCeiling, Integrity, Label, Origins}; + use jcp_schema::{ContentId, Did}; + use std::collections::BTreeSet; + + fn channel(byte: u8) -> ContentId { + ContentId::of_bytes(&[byte; 8]) + } + fn anchor() -> ContentId { + ContentId([0u8; 32]) + } + fn no_declared() -> BTreeSet { + BTreeSet::new() + } + + #[test] + fn windows_are_platform_independent_and_exact() { + // Same bytes ⇒ same fingerprint set, regardless of who computes them. + let a = windows(b"the quick brown fox jumps", SCAN_W); + let b = windows(b"the quick brown fox jumps", SCAN_W); + assert_eq!(a, b); + assert!(!a.is_empty()); + // Fewer than W bytes ⇒ no windows. + assert!(windows(b"short", SCAN_W).is_empty()); + // A 25-byte string has 25-16+1 = 10 windows. + assert_eq!(windows(b"the quick brown fox jumps", SCAN_W).len(), 10); + } + + #[test] + fn l17_carry_of_at_least_w_bytes_yields_a_witnessed_edge() { + let mut store = FlowStore::new(64); + let secret = b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij"; // > 16 bytes + let (email_id, email_label) = store.admit_output( + secret, + channel(1), + Some(Did([9u8; 32])), + &Label::bottom(), + jcp_schema::flow::Readers::Public, + &[], + anchor(), + ); + assert_eq!(email_label.integrity, Integrity::Untrusted); + + // A "summary" that carries a verbatim run of the email. + let summary = b"Summary: ATTACKER-CONTROLLED-PAYLOAD-abcdefghij was mentioned."; + let (_id, label, witness) = store.label_payload(summary, &no_declared(), Mode::Explicit, anchor()); + + let edge = witness.edges.iter().find(|e| e.parent == email_id).expect("edge to the email"); + assert_eq!(edge.kind, EdgeKind::Witnessed, "L17: verbatim carry is witnessed"); + assert!(!edge.grams.is_empty(), "the overlap is proven by grams"); + // The taint propagated: the summary is now untrusted and carries the origin. + assert_eq!(label.integrity, Integrity::Untrusted); + assert!(!label.origins.is_empty(), "summary carries the email's origin"); + assert!(verify_witness(&witness).is_ok()); + } + + #[test] + fn sub_w_unregistered_carry_is_not_found() { + // The documented residual, pinned as a test so it can never be mistaken for + // a guarantee: a short verbatim token below the window floor, not registered, + // produces no witnessed edge. + let mut store = FlowStore::new(64); + let (_id, _l) = store.admit_output(b"code=A1B2C3", channel(1), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[], anchor()); + let (_id, label, witness) = + store.label_payload(b"the value A1B2C3 appears here", &no_declared(), Mode::Explicit, anchor()); + assert!(witness.edges.is_empty(), "a sub-W token yields no scan edge"); + assert!(label.is_bottom(), "so nothing propagates"); + } + + #[test] + fn l19_a_registered_sensitive_term_of_any_length_is_found() { + let mut store = FlowStore::new(64); + // An 11-char account number, registered by the boundary adapter. + let (src_id, _l) = store.admit_output(b"balance page", channel(2), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[b"12345678901"], anchor()); + + let (_id, label, witness) = + store.label_payload(b"transfer from 12345678901 now", &no_declared(), Mode::Explicit, anchor()); + let edge = witness.edges.iter().find(|e| e.parent == src_id).expect("edge to the source"); + assert_eq!(edge.kind, EdgeKind::Witnessed, "L19: a registered term taints even below W"); + assert_eq!(edge.witnessed_bytes, 11); + assert!(!label.is_bottom()); + assert!(verify_witness(&witness).is_ok()); + } + + #[test] + fn l18_a_shared_window_only_ever_adds_a_parent() { + // Over-approximation soundness: two admitted values sharing a window both + // become parents of anything carrying it — a match is never missed, and a + // (spurious) collision would only add taint, never remove it. + let mut store = FlowStore::new(64); + let run = b"SHARED-VERBATIM-RUN-0123456789"; + let (a, _) = store.admit_output(run, channel(1), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[], anchor()); + let (b, _) = store.admit_output(run, channel(2), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[], anchor()); + // Distinct channels ⇒ distinct content? No — identical bytes ⇒ same id. + assert_eq!(a, b, "identical bytes content-address identically"); + + // Two *different* payloads sharing one window. + let mut store = FlowStore::new(64); + let (p, _) = store.admit_output(b"prefix SHARED-VERBATIM-RUN-0123456789 alpha", + channel(1), None, &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + let (q, _) = store.admit_output(b"beta SHARED-VERBATIM-RUN-0123456789 suffix", + channel(2), None, &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + assert_ne!(p, q); + let (_id, _label, witness) = store.label_payload( + b"carries SHARED-VERBATIM-RUN-0123456789 from both", &no_declared(), Mode::Explicit, anchor()); + let parents: BTreeSet = witness.edges.iter().map(|e| e.parent).collect(); + assert!(parents.contains(&p) && parents.contains(&q), "both sharers are parents, never missed"); + } + + #[test] + fn l20_verify_refolds_the_cut_and_catches_tampering() { + let mut store = FlowStore::new(64); + store.admit_output(b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", channel(1), Some(Did([9u8; 32])), + &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + let (_id, _label, witness) = store.label_payload( + b"echo ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", &no_declared(), Mode::Explicit, anchor()); + assert!(verify_witness(&witness).is_ok(), "a well-formed witness verifies"); + + // Tamper the sealed label to look clean; the refold of the cut exposes it. + let mut forged = witness.clone(); + forged.label = Label::bottom(); + assert_eq!(verify_witness(&forged), Err(WitnessError::FoldMismatch)); + } + + #[test] + fn a_clean_payload_stays_bottom_and_is_byte_identical_ground() { + let mut store = FlowStore::new(64); + store.admit_principal(b"a principal instruction", anchor()); + let (_id, label, witness) = + store.label_payload(b"an unrelated derived value", &no_declared(), Mode::Explicit, anchor()); + assert!(label.is_bottom(), "no untrusted ancestor ⇒ ⊥"); + assert!(witness.edges.is_empty()); + assert!(verify_witness(&witness).is_ok()); + } + + #[test] + fn a_declared_but_absent_parent_fails_closed_to_top() { + let mut store = FlowStore::new(64); + let phantom = ContentId::of_bytes(b"a value never admitted"); + let declared: BTreeSet = [phantom].into_iter().collect(); + let (_id, label, witness) = + store.label_payload(b"derived from something we lost", &declared, Mode::Explicit, anchor()); + assert_eq!(label, Label::top(), "a lost declared dependency folds to ⊤"); + assert!(verify_witness(&witness).is_ok(), "the ⊤ fold is witness-consistent"); + } + + #[test] + fn the_attack_shape_a_ceiling_would_deny() { + // The store side of read_email → summarize → send_email: after summarizing + // an attacker email, the summary's label does not sit below a Principal-only + // ceiling, which is exactly what the runtime gate will reject. + let mut store = FlowStore::new(64); + store.admit_output(b"From attacker: EXFIL-THIS-SECRET-abcdefghij", channel(7), + Some(Did([3u8; 32])), &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + let (_id, summary_label, _w) = store.label_payload( + b"The email said EXFIL-THIS-SECRET-abcdefghij", &no_declared(), Mode::Explicit, anchor()); + + // A send_email grant permitting only clean, principal-origin arguments. + let ceiling = FlowCeiling::new(Label { + integrity: Integrity::Principal, + origins: Origins::none(), + readers: jcp_schema::flow::Readers::Public, + }); + assert!(!ceiling.admits(&summary_label), "the tainted summary must not pass the ceiling"); + // And a bottom argument still would. + assert!(ceiling.admits(&Label::bottom())); + } + + #[test] + fn high_water_floor_taints_a_laundered_payload_when_the_scan_cannot() { + // The negative companion to the carry test: base64/paraphrase defeats the + // window index, but HighWater's session floor still denies at the sink. + let mut store = FlowStore::new(64); + store.admit_output(b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", channel(1), + Some(Did([9u8; 32])), &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + + // A laundered value sharing no window with the email. + let laundered = b"totally different words with no overlap whatsoever xyzzy"; + let (_id, explicit_label, _w) = + store.label_payload(laundered, &no_declared(), Mode::Explicit, anchor()); + assert!(explicit_label.is_bottom(), "the scan alone cannot see laundering (documented residual)"); + + // But HighWater joins the floor, which the untrusted read already raised. + let (_id, hw_label, witness) = + store.label_payload(laundered, &no_declared(), Mode::HighWater, anchor()); + assert_eq!(hw_label.integrity, Integrity::Untrusted, "the floor still taints the sink"); + assert!(witness.edges.iter().any(|e| e.kind == EdgeKind::Ambient)); + } +} diff --git a/jcp/jcp-rs/crates/jcp-flow/src/store.rs b/jcp/jcp-rs/crates/jcp-flow/src/store.rs new file mode 100644 index 000000000..2042d0161 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/src/store.rs @@ -0,0 +1,353 @@ +//! The session store and the exact carry detector. +//! +//! The store holds every value a run has admitted — tool outputs, principal +//! messages — each with its flow label, indexed by an **exact** window +//! fingerprint. When a later value is computed, [`FlowStore::label_payload`] folds +//! its label from the ancestors it verbatim-carries bytes from, plus any the +//! caller declared, plus (optionally) a coarse session floor. +//! +//! The detector is an exact `blake3` window index, deliberately *not* a rolling +//! hash: a rolling hash carries state between windows and a carry-arithmetic bug +//! silently misses overlaps. Here each W-byte window is hashed independently, so +//! two byte sequences share a fingerprint exactly when they share a verbatim +//! W-byte run — provable, platform-independent, with no state to get wrong. + +use std::collections::{BTreeMap, BTreeSet}; + +use jcp_schema::flow::{Integrity, Label, Origin, Origins, Readers}; +use jcp_schema::{ContentId, Did}; +use serde::{Deserialize, Serialize}; + +use crate::witness::{CutNode, Edge, EdgeKind, Witness, WITNESS_SCHEMA}; + +/// The detection floor: overlaps of at least this many verbatim bytes are found; +/// shorter runs are not (a registered sensitive term closes the sub-window gap). +pub const SCAN_W: usize = 16; + +/// Domain separation for the window hash, so a fingerprint here cannot collide by +/// construction with any other `blake3` use in the system. +const WINDOW_DOMAIN: &[u8] = b"jcp/flow/window/v1"; + +/// The scan parameters pinned into every [`Witness`], so a replay uses the exact +/// window size and the exact sensitive-term commitment the verdict was made under. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScanParams { + pub w: u16, + /// Content address of the registered sensitive-term set at emission time. + pub sensitive_root: ContentId, +} + +/// Every W-byte window of `bytes`, each hashed independently with a domain-keyed +/// `blake3` and truncated to `u64`. Fewer than `w` bytes yields no windows — the +/// sub-window gap the sensitive-term index exists to cover. +pub fn windows(bytes: &[u8], w: usize) -> BTreeSet { + let mut out = BTreeSet::new(); + if w == 0 || bytes.len() < w { + return out; + } + let key = *blake3::hash(WINDOW_DOMAIN).as_bytes(); + for start in 0..=bytes.len() - w { + let digest = blake3::keyed_hash(&key, &bytes[start..start + w]); + let mut eight = [0u8; 8]; + eight.copy_from_slice(&digest.as_bytes()[0..8]); + out.insert(u64::from_le_bytes(eight)); + } + out +} + +/// Naive verbatim substring search over bytes — the sub-window sensitive-term test. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || needle.len() > haystack.len() { + return false; + } + haystack.windows(needle.len()).any(|window| window == needle) +} + +/// Which mode [`FlowStore::label_payload`] folds in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// Only explicit evidence: verbatim carry, registered terms, and declarations. + Explicit, + /// Adds the monotone session floor — everything after the first untrusted read + /// joins it. A blunt over-approximation of implicit flow; sound, not precise. + HighWater, +} + +/// What the store knows about one admitted value. +#[derive(Clone, Debug)] +struct Admitted { + label: Label, + /// Receipt-log head at admission — the anchor a receipt replay uses to prove + /// this value existed before anything derived from it. + anchor: ContentId, + /// This value's own window fingerprints, kept so eviction can remove them + /// without rescanning. + windows: BTreeSet, +} + +/// The session's flow state: labels, the exact window index, registered sensitive +/// terms, and the coarse floor. +pub struct FlowStore { + admitted: BTreeMap, + /// fingerprint → the admitted ids that contain that window. + window_index: BTreeMap>, + /// registered short/exact secret → the source ids that carry it. + sensitive: BTreeMap, BTreeSet>, + /// FIFO admission order, for deterministic eviction. + order: Vec, + /// The monotone `HighWater` floor. + high_water: Label, + /// Receipt-log head at the last admission — the next witness's anchor. + head: ContentId, + cap: usize, + w: usize, +} + +/// The sentinel parent id an `Ambient` edge points at (the session floor has no +/// single source payload). +pub fn ambient_source() -> ContentId { + ContentId::of_bytes(b"jcp/flow/high-water") +} + +impl FlowStore { + /// A store holding at most `cap` values (FIFO eviction past that) with window + /// size [`SCAN_W`]. + pub fn new(cap: usize) -> Self { + FlowStore { + admitted: BTreeMap::new(), + window_index: BTreeMap::new(), + sensitive: BTreeMap::new(), + order: Vec::new(), + high_water: Label::bottom(), + head: ContentId([0u8; 32]), + cap: cap.max(1), + w: SCAN_W, + } + } + + pub fn head(&self) -> ContentId { + self.head + } + + pub fn get(&self, id: &ContentId) -> Option<&Label> { + self.admitted.get(id).map(|entry| &entry.label) + } + + /// The content address of the registered sensitive-term set — pinned into a + /// witness so a replay knows which terms were in force. + pub fn sensitive_root(&self) -> ContentId { + let terms: Vec<&Vec> = self.sensitive.keys().collect(); + ContentId::of(&terms).expect("term set is canonically serializable") + } + + fn params(&self) -> ScanParams { + ScanParams { w: self.w as u16, sensitive_root: self.sensitive_root() } + } + + fn register_terms(&mut self, id: ContentId, terms: &[&[u8]]) { + for term in terms { + if term.is_empty() { + continue; + } + self.sensitive.entry(term.to_vec()).or_default().insert(id); + } + } + + /// Insert a value with a fixed label, indexing its windows and advancing the + /// floor. Shared by the boundary admissions and by `label_payload`. + fn admit(&mut self, bytes: &[u8], label: Label, anchor: ContentId) -> ContentId { + let id = ContentId::of_bytes(bytes); + let value_windows = windows(bytes, self.w); + for fingerprint in &value_windows { + self.window_index.entry(*fingerprint).or_default().insert(id); + } + // A re-admission of identical bytes keeps the earlier (already-anchored) + // entry; its label is content-determined and equal. + if !self.admitted.contains_key(&id) { + self.order.push(id); + } + self.admitted.insert(id, Admitted { label: label.clone(), anchor, windows: value_windows }); + self.high_water = self.high_water.join(&label); + self.evict_if_needed(); + id + } + + fn evict_if_needed(&mut self) { + while self.order.len() > self.cap { + let victim = self.order.remove(0); + if let Some(entry) = self.admitted.remove(&victim) { + for fingerprint in &entry.windows { + if let Some(ids) = self.window_index.get_mut(fingerprint) { + ids.remove(&victim); + if ids.is_empty() { + self.window_index.remove(fingerprint); + } + } + } + } + // An evicted id, if later declared, is named-but-absent ⇒ folded to ⊤ + // (fail closed). Its sensitive registrations are left; a term still + // points at a gone id, which `label_payload` treats as absent ⇒ ⊤. + } + } + + /// Boundary: admit a tool **result**. Its label is the channel origin (always + /// `Untrusted`, joined with the args that produced the call and the tool's + /// reader policy), and any adapter-declared sensitive terms are registered. + pub fn admit_output( + &mut self, + bytes: &[u8], + channel: ContentId, + principal: Option, + args: &Label, + readers: Readers, + terms: &[&[u8]], + anchor: ContentId, + ) -> (ContentId, Label) { + let origin = match principal { + Some(did) => Origin::from(channel, did), + None => Origin::channel(channel), + }; + let channel_label = Label { + integrity: Integrity::Untrusted, + origins: Origins::one(origin), + readers, + }; + let label = channel_label.join(args); + let id = self.admit(bytes, label.clone(), anchor); + self.register_terms(id, terms); + self.head = id; + (id, label) + } + + /// Boundary: admit a **principal** message — a clean, bottom-labelled value. + pub fn admit_principal(&mut self, bytes: &[u8], anchor: ContentId) -> (ContentId, Label) { + let id = self.admit(bytes, Label::bottom(), anchor); + self.head = id; + (id, Label::bottom()) + } + + /// The propagation entry point: fold a payload's label from every ancestor it + /// verbatim-carries bytes from (`Witnessed`), every registered sensitive term + /// it contains (`Witnessed`), every caller-declared parent (`Declared`, or ⊤ + /// if that parent is absent — fail closed), and — in `HighWater` mode — the + /// session floor (`Ambient`). Admits the payload under that label and returns a + /// self-contained [`Witness`]. + pub fn label_payload( + &mut self, + bytes: &[u8], + declared: &BTreeSet, + mode: Mode, + anchor: ContentId, + ) -> (ContentId, Label, Witness) { + let subject_windows = windows(bytes, self.w); + let params = self.params(); + + // 1. Window-found parents and the grams that prove each overlap. + let mut grams_by_parent: BTreeMap> = BTreeMap::new(); + for fingerprint in &subject_windows { + if let Some(ids) = self.window_index.get(fingerprint) { + for id in ids { + grams_by_parent.entry(*id).or_default().insert(*fingerprint); + } + } + } + + // 2. Sensitive-found parents and the longest matching term per parent. + let mut sensitive_bytes: BTreeMap = BTreeMap::new(); + for (term, sources) in &self.sensitive { + if contains(bytes, term) { + for source in sources { + let entry = sensitive_bytes.entry(*source).or_insert(0); + *entry = (*entry).max(term.len()); + } + } + } + + // 3. Assemble the cut and edges over the union of parents. + let mut parent_ids: BTreeSet = BTreeSet::new(); + parent_ids.extend(grams_by_parent.keys().copied()); + parent_ids.extend(sensitive_bytes.keys().copied()); + parent_ids.extend(declared.iter().copied()); + + let mut cut: Vec = Vec::new(); + let mut edges: Vec = Vec::new(); + + for parent in &parent_ids { + match self.admitted.get(parent) { + Some(entry) => { + cut.push(CutNode { id: *parent, label: entry.label.clone(), anchor: entry.anchor }); + if let Some(grams) = grams_by_parent.get(parent) { + // Verbatim overlap: at least one W-window matched, so at + // least W bytes are verbatim (a sound lower bound). + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Witnessed, + witnessed_bytes: self.w as u64, + grams: grams.clone(), + }); + } else if let Some(bytes_len) = sensitive_bytes.get(parent) { + // A registered term appears verbatim. Below the window floor + // there are no grams; the byte count is the evidence. + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Witnessed, + witnessed_bytes: *bytes_len as u64, + grams: BTreeSet::new(), + }); + } else { + // Declared with no verbatim overlap: verifiable only via a + // named artifact. + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Declared, + witnessed_bytes: 0, + grams: BTreeSet::new(), + }); + } + } + None => { + // Declared but absent (e.g. evicted): fail closed — fold ⊤ so + // the subject inherits maximal taint rather than silently + // dropping a dependency. + cut.push(CutNode { id: *parent, label: Label::top(), anchor: ContentId([0u8; 32]) }); + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Declared, + witnessed_bytes: 0, + grams: BTreeSet::new(), + }); + } + } + } + + // 4. HighWater: join the coarse session floor as an Ambient edge. + if mode == Mode::HighWater && !self.high_water.is_bottom() { + let ambient = ambient_source(); + cut.push(CutNode { id: ambient, label: self.high_water.clone(), anchor: self.head }); + edges.push(Edge { + parent: ambient, + kind: EdgeKind::Ambient, + witnessed_bytes: 0, + grams: BTreeSet::new(), + }); + } + + let label = jcp_schema::flow::fold( + &cut.iter().map(|node| node.label.clone()).collect::>(), + ); + + let id = self.admit(bytes, label.clone(), anchor); + self.head = id; + + let witness = Witness { + schema: WITNESS_SCHEMA.to_string(), + params, + subject: id, + label: label.clone(), + cut, + edges, + }; + (id, label, witness) + } +} diff --git a/jcp/jcp-rs/crates/jcp-flow/src/witness.rs b/jcp/jcp-rs/crates/jcp-flow/src/witness.rs new file mode 100644 index 000000000..c9581d918 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/src/witness.rs @@ -0,0 +1,145 @@ +//! The witness — the content-addressed, offline-checkable record of *why* a value +//! carries the label it does. +//! +//! A receipt commits only the witness's `id()`. An entitled auditor fetches the +//! witness and rechecks it: re-fold the disclosed ancestor cut and confirm it +//! reproduces the sealed label ([`verify_witness`]). That is what makes a +//! provenance verdict replayable by a third party rather than a claim to trust. + +use std::collections::BTreeSet; + +use jcp_schema::{flow::Label, ContentId}; +use serde::{Deserialize, Serialize}; + +/// How strongly a taint edge is justified — sealed, so a receipt shows whether a +/// verdict rested on hard bytes or the conservative floor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EdgeKind { + /// A verbatim overlap the scan found between this value and an ancestor: the + /// grams prove it and a third party rechecks them. The strongest evidence. + Witnessed, + /// The caller (or a registered sensitive term) named this ancestor. Verifiable + /// only with the named artifact — a citation, an endorsement, or the term set. + Declared, + /// The coarse session floor (`HighWater` mode): everything after the first + /// untrusted read joins it. Sound but imprecise, and flagged as such in replay. + Ambient, +} + +fn u64_is_zero(value: &u64) -> bool { + *value == 0 +} + +/// One taint edge: a derived value depends on an ancestor payload, and this says +/// how that was established. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Edge { + /// The ancestor payload's content id — its key in the [`crate::FlowStore`] and + /// the cut node it justifies. + pub parent: ContentId, + pub kind: EdgeKind, + /// For a `Witnessed` edge, how many verbatim bytes were matched (informational; + /// the grams are the proof). + #[serde(skip_serializing_if = "u64_is_zero", default)] + pub witnessed_bytes: u64, + /// The shared window fingerprints proving the overlap. A verifier rechecks + /// these are a subset of both endpoints' window sets. Empty for `Declared` and + /// `Ambient` edges, and for a sub-window sensitive-term match. + #[serde(skip_serializing_if = "BTreeSet::is_empty", default)] + pub grams: BTreeSet, +} + +/// A direct ancestor in the cut: its content id, the label it was admitted with, +/// and the receipt-log head at admission time (the anchor a receipt replay uses to +/// prove the read happened before this value existed). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CutNode { + pub id: ContentId, + pub label: Label, + pub anchor: ContentId, +} + +/// The minimal ancestor cut, its edges, and the folded label — self-contained and +/// content-addressed. The one artifact a third party needs (beyond the receipt +/// chain) to re-derive a flow verdict offline. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Witness { + /// Schema tag, `"flow/1"`. + pub schema: String, + /// The scan parameters in force when this witness was emitted — pinned so a + /// replay uses the exact window size and sensitive-term commitment. + pub params: crate::store::ScanParams, + /// The payload this witness explains (`== frame.payload_hash` of the call). + pub subject: ContentId, + /// The folded effective label — what the ceiling was tested against. + pub label: Label, + /// Each direct ancestor. + pub cut: Vec, + /// How each ancestor taints the subject. + pub edges: Vec, +} + +/// The witness schema tag. +pub const WITNESS_SCHEMA: &str = "flow/1"; + +impl Witness { + /// The content id a receipt commits to. Order-free because every field is + /// canonically serialized (`BTree`/`Vec` in insertion order the store fixes). + pub fn id(&self) -> ContentId { + ContentId::of(self).expect("witness is canonically serializable") + } +} + +/// Why a witness failed to verify. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WitnessError { + #[error("witness schema is not {WITNESS_SCHEMA}")] + BadSchema, + /// Re-folding the cut did not reproduce the sealed label — the witness does not + /// justify its own verdict (violates the fold law L20). + #[error("re-folded label does not match the sealed label")] + FoldMismatch, + /// An edge names a parent that is not in the cut. + #[error("edge parent {0:?} is not in the cut")] + DanglingEdge(ContentId), + /// A `Witnessed` edge carries no evidence (no grams and zero bytes). + #[error("witnessed edge to {0:?} carries no evidence")] + EmptyWitnessedEdge(ContentId), +} + +/// Verify a witness against itself: the schema is right, every edge points at a +/// cut node, every `Witnessed` edge carries evidence, and — the load-bearing check +/// (law L20) — re-folding the cut's labels reproduces the sealed label. +/// +/// This is the *consistency* tier: it proves the witness is internally coherent and +/// that its verdict follows from its own cut. It does **not** re-derive the grams +/// from the original bytes; that truth tier requires disclosing the endpoint bytes +/// and is a separate, opt-in check (see the crate residuals). Consistency is enough +/// to catch a witness that was tampered to soften its own verdict, because the +/// label is refolded from the cut rather than trusted. +pub fn verify_witness(witness: &Witness) -> Result<(), WitnessError> { + if witness.schema != WITNESS_SCHEMA { + return Err(WitnessError::BadSchema); + } + + let cut_ids: BTreeSet<&ContentId> = witness.cut.iter().map(|node| &node.id).collect(); + for edge in &witness.edges { + if !cut_ids.contains(&edge.parent) { + return Err(WitnessError::DanglingEdge(edge.parent)); + } + if edge.kind == EdgeKind::Witnessed && edge.grams.is_empty() && edge.witnessed_bytes == 0 { + return Err(WitnessError::EmptyWitnessedEdge(edge.parent)); + } + } + + // L20 — re-fold the cut. Join is commutative and associative, so any order + // reproduces the same label; a tampered `label` field is caught here. + let refolded = jcp_schema::flow::fold( + &witness.cut.iter().map(|node| node.label.clone()).collect::>(), + ); + if refolded != witness.label { + return Err(WitnessError::FoldMismatch); + } + Ok(()) +} diff --git a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml index 111e9ad09..ce35e114d 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml @@ -13,6 +13,7 @@ jcp-grant = { workspace = true } jcp-runtime = { workspace = true } jcp-receipt = { path = "../jcp-receipt" } jcp-bridge = { path = "../jcp-bridge" } +jcp-flow = { path = "../jcp-flow" } sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" } ed25519-dalek = { workspace = true } serde_json = { workspace = true } diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index df97b91e9..da9073ca4 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -29,14 +29,16 @@ //! stdio binary implements over a child process and tests implement as a mock. use ed25519_dalek::SigningKey; -use jcp_bridge::{mcp_call_frame, mcp_resource}; +use jcp_bridge::{mcp_call_frame, mcp_resource, mcp_result_frame}; +use jcp_flow::{FlowStore, Mode, Witness}; use jcp_grant::issue; -use jcp_receipt::ReceiptLog; +use jcp_receipt::{FlowSeal, ReceiptLog}; use jcp_runtime::Runtime; -use jcp_schema::{Capability, Did, Energy, Grant, SignedGrant}; +use jcp_schema::flow::{FlowCeiling, Readers}; +use jcp_schema::{Capability, Caveat, ContentId, Did, Energy, FlowFacts, Grant, SignedGrant}; use sandbox_meter::PowerMeter; use serde_json::{json, Value}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// The upstream MCP server, abstracted so the gateway's policy is testable /// without spawning a process. `call` sends one JSON-RPC request line and @@ -69,6 +71,18 @@ pub struct Gateway { meter: PowerMeter, log: ReceiptLog, now: u64, + /// Signs re-issued grants when a flow ceiling is attached to a tool. The same + /// fixed local issuer `new` uses — the consumer is its own grant authority. + issuer_sk: SigningKey, + /// Propagated provenance across this session's calls: a tool result is admitted + /// as a labelled source, and a later call's arguments are folded against it. + flow: FlowStore, + /// How flow is folded — explicit evidence only, or with the coarse `HighWater` + /// session floor. + mode: Mode, + /// The witnesses this session emitted, by id — the disclosure store an auditor + /// fetches to re-derive a sealed verdict. + witnesses: BTreeMap, } impl Gateway { @@ -105,9 +119,45 @@ impl Gateway { meter: PowerMeter::detect(), log: ReceiptLog::new(steward_sk, format!("did:jcp:gateway:{server}")), now: 0, + issuer_sk: issuer, + flow: FlowStore::new(4096), + mode: Mode::Explicit, + witnesses: BTreeMap::new(), } } + /// Attach a flow ceiling to a tool: re-issue its grant with a + /// [`Caveat::FlowBelow`], so a call whose arguments carry provenance above the + /// ceiling is denied at the gate. A no-op if the tool is not granted. + pub fn set_flow_ceiling(&mut self, tool: &str, ceiling: FlowCeiling) { + let Some(sg) = self.grants.get(tool) else { return }; + let mut grant = sg.grant.clone(); + grant.caveats.retain(|c| c.flow_ceiling().is_none()); + grant.caveats.push(Caveat::FlowBelow(ceiling)); + if let Ok(reissued) = issue(&self.issuer_sk, grant) { + self.grants.insert(tool.to_string(), reissued); + } + } + + /// Turn on the coarse `HighWater` session floor — every call after the first + /// untrusted read joins the session's taint. A blunt over-approximation of + /// implicit flow; off by default. + pub fn enable_high_water(&mut self) { + self.mode = Mode::HighWater; + } + + /// Forbid values carrying `mask`'s origins from reaching `tool`, regardless of + /// grant — an enforcement-point taint floor (SPEC §4.10). + pub fn forbid_flow(&mut self, tool: &str, mask: jcp_schema::flow::Origins) { + self.runtime.forbid_flow(mcp_resource(&self.server, tool), mask); + } + + /// The witness for a sealed flow verdict, by id — the disclosure an auditor + /// fetches to re-derive the verdict offline. + pub fn witness(&self, id: &ContentId) -> Option<&Witness> { + self.witnesses.get(id) + } + /// The granted tool names, sorted. pub fn tools(&self) -> Vec<&str> { self.grants.keys().map(String::as_str).collect() @@ -159,11 +209,34 @@ impl Gateway { let grant = sg.grant.clone(); let frame = mcp_call_frame(grant.id(), &self.server, tool, &args_bytes); - // Phase 1: authorize without debiting. - let auth = self.runtime.authorize(&frame, &sg, self.caller, self.now); + // Phase 0: fold the argument's provenance. The scan finds any verbatim + // carry from a tool result admitted earlier this session; the monitor's own + // energy for this is metered and sealed separately, never as tool cost. + let declared = declared_witness_ids(¶ms); + let anchor = self.log.head(); + let mode = self.mode; + let (witness, eval) = { + let flow = &mut self.flow; + let ((_id, _label, witness), m) = + self.meter.measure(|| flow.label_payload(&args_bytes, &declared, mode, anchor)); + (witness, m) + }; + let facts = FlowFacts { + label: witness.label.clone(), + witness_id: witness.id(), + eval_uj: eval.total_uj(), + eval_prov: eval_provenance(&self.meter), + }; + let seal = FlowSeal { facts: Some(facts.clone()), ceiling: ceiling_digest(&grant) }; + // Keep the witness discoverable for a later audit of this verdict. + self.witnesses.insert(facts.witness_id, witness); + + // Phase 1: authorize with the flow facts — a tainted argument above the + // ceiling is denied here, before any debit (zero joules). + let auth = self.runtime.authorize_flow(&frame, &sg, self.caller, self.now, &facts); if !auth.is_allow() { let reason = deny_reason_of(&auth); - self.log.append(&frame, &grant, &auth); + self.log.append_flow(&frame, &grant, &auth, &seal); return Ok(Relayed::Denied { response: jsonrpc_error(id, -32001, &format!("JCP denied: {reason}")), reason, @@ -181,9 +254,25 @@ impl Gateway { }; let measured_uj = measurement.total_uj(); - // Phase 3: debit the MEASURED joules; seal the accounting decision. - let decision = self.runtime.charge(&grant, Energy::measured(measured_uj)); - self.log.append(&frame, &grant, &decision); + // Phase 3: debit the measured joules (and the tainted sub-budget, if the + // call was tainted); seal the accounting decision as jcp/3 when tainted. + let decision = self.runtime.charge_flow(&grant, Energy::measured(measured_uj), &facts); + self.log.append_flow(&frame, &grant, &decision, &seal); + + // Phase 4: the result becomes a labelled source. Its frame is Trust::Data + // (an injected instruction in it can never exercise a capability), and it + // is admitted so a later call carrying its bytes is folded against it — the + // whole point: derived taint that outlives this one call. + let _result_frame = mcp_result_frame(grant.id(), &self.server, tool, response.as_bytes()); + self.flow.admit_output( + response.as_bytes(), + mcp_resource(&self.server, tool), + None, + &facts.label, + Readers::Public, + &[], + self.log.head(), + ); Ok(Relayed::Allowed { response, @@ -213,6 +302,37 @@ impl Gateway { } } +/// The digest of a grant's flow ceiling, for the receipt seal. `None` when the +/// grant carries no flow caveat. +fn ceiling_digest(grant: &Grant) -> Option { + grant.caveats.iter().find_map(|c| c.flow_ceiling()).and_then(|fc| ContentId::of(fc).ok()) +} + +/// The caller-declared ancestor witness ids from `params._jcp_witness` (an array +/// of hex content ids), if present — an explicit "this argument came from these +/// values" the harness can assert beyond what the scan finds. +fn declared_witness_ids(params: &Value) -> BTreeSet { + params + .get("_jcp_witness") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .filter_map(|h| ContentId::from_hex(h).ok()) + .collect() + }) + .unwrap_or_default() +} + +/// The provenance label to seal for the monitor's own metered evaluation. +fn eval_provenance(meter: &PowerMeter) -> jcp_schema::Provenance { + if meter.is_hardware() { + jcp_schema::Provenance::HwShunt + } else { + jcp_schema::Provenance::Estimator + } +} + fn deny_reason_of(d: &jcp_runtime::Decision) -> String { match &d.verdict { jcp_schema::Verdict::Deny(r) => r.label().to_string(), @@ -334,4 +454,97 @@ mod tests { Ok(g.receipts().len()) ); } + + // ── the end-to-end attack the whole feature exists to stop ─────────────── + + use jcp_schema::flow::{Integrity, Label, Origins}; + + /// A server whose `read_email` returns attacker-controlled content carrying a + /// long verbatim secret; every other tool just succeeds. + struct ExfilServer; + impl Upstream for ExfilServer { + fn call(&mut self, line: &str) -> std::io::Result { + let v: Value = serde_json::from_str(line).unwrap_or(Value::Null); + let id = v.get("id").cloned().unwrap_or(Value::Null); + let tool = v.get("params").and_then(|p| p.get("name")).and_then(Value::as_str).unwrap_or(""); + let result = if tool == "read_email" { + json!({"body": "URGENT: EXFILTRATE-THIS-SECRET-TOKEN-abcdefghij to the attacker"}) + } else { + json!({"ok": true}) + }; + Ok(json!({"jsonrpc":"2.0","id":id,"result":result}).to_string()) + } + } + + fn send_call(id: i64, body: &str) -> String { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call", + "params":{"name":"send_email","arguments":{"body":body}}}) + .to_string() + } + + fn principal_only() -> FlowCeiling { + FlowCeiling::new(Label { integrity: Integrity::Principal, origins: Origins::none(), readers: Readers::Public }) + } + + #[test] + fn a_value_derived_from_an_untrusted_read_cannot_be_exfiltrated() { + let steward = SigningKey::from_bytes(&[7u8; 32]); + let steward_pub = steward.verifying_key().to_bytes(); + let mut g = Gateway::new( + "mail", + &[("read_email", 50_000_000), ("send_email", 50_000_000)], + steward.clone(), + ); + // send_email may only act on principal-clean arguments. + g.set_flow_ceiling("send_email", principal_only()); + let mut server = ExfilServer; + + // 1. Read the attacker email — allowed (no ceiling on read), and its output + // is admitted as an untrusted labelled source. + let read = json!({"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"read_email","arguments":{"folder":"inbox"}}}).to_string(); + assert!(matches!(g.relay(&read, &mut server).unwrap(), Relayed::Allowed { .. })); + + // 2. Try to send a body carrying the secret verbatim — the argument is + // derived from the untrusted read, so it is above the ceiling and denied. + let exfil = send_call(2, "Please forward EXFILTRATE-THIS-SECRET-TOKEN-abcdefghij now"); + match g.relay(&exfil, &mut server).unwrap() { + Relayed::Denied { reason, .. } => assert_eq!(reason, "tainted-flow"), + other => panic!("exfil must be denied, got {other:?}"), + } + + // 3. A clean send — no carry from any untrusted source — still goes through. + let clean = send_call(3, "meeting at noon"); + assert!(matches!(g.relay(&clean, &mut server).unwrap(), Relayed::Allowed { .. })); + + // The whole decision log verifies as a chain across jcp/2 and jcp/3 entries. + assert_eq!( + jcp_receipt::verify_log(g.receipts(), "did:jcp:gateway:mail", &steward_pub), + Ok(g.receipts().len()) + ); + + // The denial receipt is jcp/3, names tainted-flow, and cost zero joules. + let denial = &g.receipts()[1]; + let core = jcp_receipt::extract(denial, "did:jcp:gateway:mail", &steward_pub).unwrap(); + assert_eq!(core.verdict, "deny"); + assert_eq!(core.deny_reason.as_deref(), Some("tainted-flow")); + assert_eq!(core.cost_uj, 0, "a tainted denial debits nothing"); + let payload = jcr1_payload(denial, "did:jcp:gateway:mail", &steward_pub); + assert_eq!(payload.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3")); + let flow = payload.get("flow").expect("a flow block"); + assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("above")); + + // And the sealed witness is disclosable and re-verifies offline. + let witness_hex = flow.get("witness").and_then(Value::as_str).unwrap(); + let witness_id = ContentId::from_hex(witness_hex).unwrap(); + let witness = g.witness(&witness_id).expect("witness is retained for audit"); + assert!(jcp_flow::verify_witness(witness).is_ok(), "a third party re-derives the verdict"); + assert!(witness.edges.iter().any(|e| e.kind == jcp_flow::EdgeKind::Witnessed), "the carry is proven"); + } + + /// Decode a verified receipt envelope to its JSON payload (test helper). + fn jcr1_payload(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Value { + let core = jcp_receipt::jcr1::verify_cose_sign1(envelope, steward, pubkey).unwrap(); + jcp_schema::from_canonical_cbor(&core).unwrap() + } } diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index ad74ca5d1..9770d3125 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -35,6 +35,37 @@ pub const PROFILE: &str = "jcp/2"; /// (see [`verify`]), but are no longer minted. pub const PROFILE_V1: &str = "jcp/1"; +/// The **flow** profile (SPEC §8, §4.10): `jcp/2` plus a `flow` block recording a +/// call's propagated provenance verdict. Minted **only** when a call is tainted; +/// a clean call still seals as [`PROFILE`] (`jcp/2`), byte-for-byte, so nothing +/// on the existing path moves. +pub const PROFILE_FLOW: &str = "jcp/3"; + +/// What a flow verdict seals: the argument's provenance facts and, if the grant +/// carried one, the digest of the flow ceiling it was tested against. A `None` +/// or bottom `facts` means the call was clean and the receipt stays `jcp/2`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FlowSeal { + pub facts: Option, + /// The ceiling's content id ([`jcp_schema::FlowCeiling`] digest), if any. + pub ceiling: Option, +} + +impl FlowSeal { + /// A clean seal — the call touched no untrusted provenance, so the receipt is + /// an ordinary `jcp/2`. + pub fn clean() -> Self { + FlowSeal { facts: None, ceiling: None } + } + + /// Is this seal tainted (a non-bottom label)? Decides whether a receipt is + /// minted as `jcp/3` rather than `jcp/2`. + fn is_tainted(&self) -> bool { + self.facts.as_ref().is_some_and(|f| !f.is_bottom()) + } +} + + /// Project a decision to the **legacy `jcp/1`** wire — byte-for-byte what this /// library sealed before `payload_hash` existed. Retained solely so previously /// sealed receipts keep verifying; never used to mint. @@ -76,6 +107,117 @@ pub fn to_jcr1_wire(frame: &Frame, grant: &Grant, decision: &Decision, steward: Value::Object(obj) } +/// The `flow` block of a `jcp/3` receipt: the sealed provenance verdict. All +/// text/uint/array (no float/bool/null), so it survives canonical CBOR, and the +/// witness is a **commitment** — the artifact itself is disclosed out of band, not +/// carried here. +fn flow_block(facts: &jcp_schema::FlowFacts, seal: &FlowSeal, decision: &Decision) -> Value { + use jcp_schema::flow::{Integrity, Origins, Readers}; + let label = &facts.label; + let integrity = match label.integrity { + Integrity::Untrusted => "untrusted", + Integrity::Derived => "derived", + Integrity::Attested => "attested", + Integrity::Principal => "principal", + }; + let origins = match &label.origins { + Origins::Any => json!("any"), + Origins::Set(set) => { + Value::Array(set.iter().map(|o| json!(o.channel.to_hex())).collect()) + } + }; + let readers = match &label.readers { + Readers::Public => json!("public"), + Readers::Only(dids) => { + Value::Array(dids.iter().map(|d| json!(d.to_did_key())).collect()) + } + }; + // The flow verdict is about the gate specifically: "above" the ceiling exactly + // when the decision was denied for flow. A tainted call denied for another + // reason (or allowed under a permissive ceiling) passed the flow gate: "below". + let verdict = if decision.verdict == Verdict::Deny(jcp_schema::DenyReason::TaintedFlow) { + "above" + } else { + "below" + }; + let mut block = serde_json::Map::new(); + block.insert("label".into(), json!(label.digest().map(|c| c.to_hex()).unwrap_or_default())); + block.insert("integrity".into(), json!(integrity)); + block.insert("origins".into(), origins); + block.insert("readers".into(), readers); + block.insert("witness".into(), json!(facts.witness_id.to_hex())); + if let Some(ceiling) = &seal.ceiling { + block.insert("ceiling".into(), json!(ceiling.to_hex())); + } + block.insert("verdict".into(), json!(verdict)); + // The monitor's own evaluation cost — sealed as its OWN field, never folded + // into `cost_uj` (the tool's energy), so a call's energy keeps its meaning. + block.insert("flow_uj".into(), json!(facts.eval_uj)); + block.insert("flow_energy_provenance".into(), json!(facts.eval_prov.jcr1_label())); + Value::Object(block) +} + +/// Project a decision to flow-aware wire JSON. When the call is clean this is +/// byte-for-byte [`to_jcr1_wire`] (`jcp/2`); when tainted it is `jcp/3` — the same +/// core plus a `flow` block. The clean case producing identical bytes is what lets +/// a non-flow deployment and a flow deployment share a receipt stream. +pub fn to_jcr1_wire_flow( + frame: &Frame, + grant: &Grant, + decision: &Decision, + steward: &str, + seal: &FlowSeal, +) -> Value { + let mut wire = to_jcr1_wire(frame, grant, decision, steward); + if seal.is_tainted() { + if let (Value::Object(ref mut o), Some(facts)) = (&mut wire, &seal.facts) { + o.insert("jcp_receipt".into(), json!(PROFILE_FLOW)); + o.insert("flow".into(), flow_block(facts, seal, decision)); + } + } + wire +} + +/// Seal a decision with its flow verdict. A clean call yields an ordinary `jcp/2` +/// envelope indistinguishable from [`seal`]; a tainted call yields `jcp/3`. +pub fn seal_flow( + frame: &Frame, + grant: &Grant, + decision: &Decision, + key: &SigningKey, + steward: &str, + seal: &FlowSeal, +) -> Result, ReceiptError> { + let core = jcr1::signed_core_bytes(&to_jcr1_wire_flow(frame, grant, decision, steward, seal))?; + Ok(jcr1::cose_sign1_envelope(&core, key, steward)) +} + +/// Verify a flow envelope: like [`verify`], but recomputes the `jcp/3` core from +/// the same `seal`, so a `jcp/3` receipt round-trips. A `jcp/2` envelope (a clean +/// call) verifies here too, since a clean seal recomputes the `jcp/2` core. +pub fn verify_flow( + frame: &Frame, + grant: &Grant, + decision: &Decision, + seal: &FlowSeal, + envelope: &[u8], + steward: &str, + pubkey: &[u8; 32], +) -> Result<(), ReceiptError> { + let payload = jcr1::verify_cose_sign1(envelope, steward, pubkey)?; + let expected = match profile_of(&payload)?.as_str() { + PROFILE | PROFILE_FLOW => { + jcr1::signed_core_bytes(&to_jcr1_wire_flow(frame, grant, decision, steward, seal))? + } + PROFILE_V1 => jcr1::signed_core_bytes(&to_jcr1_wire_v1(frame, grant, decision, steward))?, + other => return Err(ReceiptError::UnknownProfile(other.to_string())), + }; + if payload != expected { + return Err(ReceiptError::PayloadMismatch); + } + Ok(()) +} + fn frame_kind_tag(f: &Frame) -> &'static str { use jcp_schema::FrameKind::*; match f.kind { @@ -404,6 +546,32 @@ impl ReceiptLog { env } + /// Seal a decision as the next link, carrying its flow verdict. A clean call + /// appends a `jcp/2` entry byte-identical to [`append`](Self::append); a + /// tainted call appends a `jcp/3` entry. Either way the chaining (`seq`/`prev`) + /// is identical, so a mixed stream verifies end to end via [`verify_log`]. + pub fn append_flow( + &mut self, + frame: &Frame, + grant: &Grant, + decision: &Decision, + seal: &FlowSeal, + ) -> Vec { + let mut wire = to_jcr1_wire_logged(frame, grant, decision, &self.steward, self.seq, &self.head); + if seal.is_tainted() { + if let (Value::Object(ref mut o), Some(facts)) = (&mut wire, &seal.facts) { + o.insert("jcp_receipt".into(), json!(PROFILE_FLOW)); + o.insert("flow".into(), flow_block(facts, seal, decision)); + } + } + let core = jcr1::signed_core_bytes(&wire).expect("canonical core"); + let env = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward); + self.head = jcp_schema::ContentId::of_bytes(&env); + self.seq += 1; + self.entries.push(env.clone()); + env + } + /// The sealed entries, in order. pub fn entries(&self) -> &[Vec] { &self.entries @@ -663,4 +831,101 @@ mod tests { let env = seal(&f, &g, &d, &key, "s").unwrap(); assert!(verify(&f, &g, &d, &env, "s", &key.verifying_key().to_bytes()).is_ok()); } + + // ── jcp/3 flow seal (SPEC §8, §4.10) ───────────────────────────────────── + + use jcp_schema::flow::{Integrity, Label, Origin, Origins, Readers}; + use jcp_schema::{FlowFacts, Provenance}; + + fn tainted_facts() -> FlowFacts { + FlowFacts { + label: Label { + integrity: Integrity::Untrusted, + origins: Origins::one(Origin::channel(ContentId::of_bytes(b"mail:read"))), + readers: Readers::Public, + }, + witness_id: ContentId::of_bytes(b"a witness"), + eval_uj: 42, + eval_prov: Provenance::ModelBased, + } + } + + #[test] + fn a_clean_call_is_byte_identical_to_jcp2() { + // The load-bearing compatibility property: a clean flow seal produces the + // exact bytes of the plain path, so a flow deployment and a non-flow one + // share a stream and every existing receipt is unmoved. + let (f, g, d) = scenario(); + let key = sk(7); + let plain = seal(&f, &g, &d, &key, "s").unwrap(); + let clean = seal_flow(&f, &g, &d, &key, "s", &FlowSeal::clean()).unwrap(); + assert_eq!(plain, clean, "a clean flow seal == the plain jcp/2 seal"); + // Even facts that were computed but are bottom stay jcp/2. + let bottom = FlowSeal { facts: Some(FlowFacts::bottom()), ceiling: None }; + assert_eq!(plain, seal_flow(&f, &g, &d, &key, "s", &bottom).unwrap()); + } + + #[test] + fn a_tainted_call_seals_jcp3_with_a_flow_block() { + let (f, g, d) = scenario(); + let key = sk(7); + let seal_data = FlowSeal { + facts: Some(tainted_facts()), + ceiling: Some(ContentId::of_bytes(b"the ceiling")), + }; + let env = seal_flow(&f, &g, &d, &key, "s", &seal_data).unwrap(); + + // It verifies against the same seal, and NOT as a plain jcp/2 (the flow + // block is part of the signed core). + let pk = key.verifying_key().to_bytes(); + assert!(verify_flow(&f, &g, &d, &seal_data, &env, "s", &pk).is_ok()); + assert!(verify(&f, &g, &d, &env, "s", &pk).is_err(), "jcp/3 needs the seal to verify"); + + // The profile and flow block are present and correct. + let payload = jcr1::verify_cose_sign1(&env, "s", &pk).unwrap(); + let v: Value = jcp_schema::from_canonical_cbor(&payload).unwrap(); + assert_eq!(v.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3")); + let flow = v.get("flow").expect("flow block"); + assert_eq!(flow.get("integrity").and_then(Value::as_str), Some("untrusted")); + assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("below")); // the scenario allowed + assert_eq!(flow.get("flow_uj").and_then(Value::as_u64), Some(42)); + assert_eq!(flow.get("flow_energy_provenance").and_then(Value::as_str), Some("ModelBased")); + assert!(flow.get("witness").and_then(Value::as_str).is_some()); + assert!(flow.get("ceiling").and_then(Value::as_str).is_some()); + } + + #[test] + fn a_flow_denied_call_seals_verdict_above() { + // A tainted call denied for flow records "above" the ceiling. + let (f, g, _d) = scenario(); + let denied = Decision { + verdict: Verdict::Deny(jcp_schema::DenyReason::TaintedFlow), + cost: jcp_schema::Energy::measured(0), + budget_remaining_uj: 100, + }; + let key = sk(7); + let seal_data = FlowSeal { facts: Some(tainted_facts()), ceiling: None }; + let env = seal_flow(&f, &g, &denied, &key, "s", &seal_data).unwrap(); + let payload = jcr1::verify_cose_sign1(&env, "s", &key.verifying_key().to_bytes()).unwrap(); + let v: Value = jcp_schema::from_canonical_cbor(&payload).unwrap(); + assert_eq!(v["flow"].get("verdict").and_then(Value::as_str), Some("above")); + assert_eq!(v.get("deny_reason").and_then(Value::as_str), Some("tainted-flow")); + } + + #[test] + fn a_mixed_jcp2_and_jcp3_log_verifies_end_to_end() { + let (f, g, d) = scenario(); + let key = sk(7); + let mut log = ReceiptLog::new(key.clone(), "s"); + log.append_flow(&f, &g, &d, &FlowSeal::clean()); // jcp/2 + log.append_flow(&f, &g, &d, &FlowSeal { facts: Some(tainted_facts()), ceiling: None }); // jcp/3 + log.append(&f, &g, &d); // jcp/2 plain + let pk = key.verifying_key().to_bytes(); + assert_eq!(verify_log(log.entries(), "s", &pk), Ok(3), "the chain is intact across profiles"); + + // extract reads a jcp/3 entry's core generically (it is jcp/2 + a block). + let core = extract(&log.entries()[1], "s", &pk).unwrap(); + assert_eq!(core.verdict, "allow"); + assert_eq!(core.cost_uj, 30, "cost_uj is the tool energy, untouched by flow_uj"); + } } diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index a4ed25d34..2dbf08560 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -9,9 +9,10 @@ //! loop — which is the entire point: the inverse-scaling-law result means a more //! capable model is a more dangerous one, so the gate cannot be the model. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use jcp_grant::{verify, verify_chain, GrantError, RevocationSet}; +use jcp_schema::flow::{FlowCeiling, FlowFacts, Origins}; use jcp_schema::{ContentId, DenyReason, Did, Energy, Frame, Grant, SignedGrant, SignedRevocation, Trust, Verdict}; use serde::{Deserialize, Serialize}; @@ -65,9 +66,107 @@ pub struct Runtime { /// (open); once any root is configured for a resource, a chain whose root /// issuer isn't listed is denied `untrusted-root`. trust_roots: HashMap>, + /// The enforcement-point taint floor (SPEC §4.10) — the flow analogue of + /// `trust_roots`. Default-open, so an unconfigured runtime behaves exactly as + /// before. + taint_floor: TaintFloor, + /// Joules spent against each grant's *tainted* sub-budget — the tighter wallet + /// for calls carrying untrusted provenance. Tracked separately from + /// `spent_uj`, which it never converts into. + spent_tainted_uj: HashMap, } +/// An enforcement-point-wide taint floor (SPEC §4.10): resources whose calls may +/// not carry values from certain origins, regardless of any grant. The flow +/// analogue of [`Runtime`]'s trust roots — an operator control that needs no new +/// grants and cannot be attenuated away by a delegated ceiling. +#[derive(Clone, Debug)] +pub struct TaintFloor { + forbidden: BTreeMap, + default_forbidden: Origins, +} + +impl Default for TaintFloor { + fn default() -> Self { + // Nothing forbidden by default: an empty origin mask intersects nothing, + // so an unconfigured floor is a no-op and every existing deployment is + // unaffected. + TaintFloor { forbidden: BTreeMap::new(), default_forbidden: Origins::none() } + } +} + +impl TaintFloor { + /// Does this floor refuse a value with label `l` on `resource`? True when the + /// label's origins touch the resource's forbidden mask (or the default mask, + /// where the resource has none of its own). + fn refuses(&self, resource: &ContentId, l: &jcp_schema::Label) -> bool { + let mask = self.forbidden.get(resource).unwrap_or(&self.default_forbidden); + l.origins.intersects(mask) + } +} + +/// The grant's effective flow ceiling: the [`FlowCeiling::attenuate`] fold over +/// every [`FlowBelow`](jcp_schema::Caveat::FlowBelow) caveat it carries, so +/// multiple ceilings compose to the strictest. `None` when the grant carries no +/// flow caveat — the case that behaves exactly as before this feature existed. +fn ceiling_of(g: &Grant) -> Option { + g.caveats.iter().filter_map(|c| c.flow_ceiling()).fold(None, |acc, fc| { + Some(match acc { + Some(parent) => parent.attenuate(fc), + None => fc.clone(), + }) + }) +} + +/// The flow gate (SPEC §4.10, check 6c). A value may exercise the grant only when +/// its label sits at or below the grant's ceiling **and** the resource's taint +/// floor does not refuse its origins. +/// +/// The `facts` argument distinguishes three cases deliberately: +/// - `None` — provenance was **not computed** (an old, un-migrated caller). With a +/// ceiling present this **fails closed**; a ceiling-free grant is unaffected. +/// - `Some(bottom)` — provenance computed and **clean**; passes any ceiling. +/// - `Some(tainted)` — tested against the ceiling and the floor. +fn flow_gate( + g: &Grant, + floor: &TaintFloor, + resource: &ContentId, + facts: Option<&FlowFacts>, +) -> Option { + // The enforcement-point floor applies whenever facts exist, independent of any + // grant ceiling — an operator refusal a delegated grant cannot widen. + if let Some(f) = facts { + if floor.refuses(resource, &f.label) { + return Some(DenyReason::TaintedFlow); + } + } + match (ceiling_of(g), facts) { + // No ceiling: today's behaviour, for any facts including None. + (None, _) => None, + // Ceiling present but provenance not computed: fail closed. + (Some(_), None) => Some(DenyReason::TaintedFlow), + (Some(ceiling), Some(f)) => { + if ceiling.admits(&f.label) { + None + } else { + Some(DenyReason::TaintedFlow) + } + } + } +} + +/// The effective ceiling across a delegation chain — the attenuation fold of every +/// link's ceiling, so a chain is at most as permissive as its strictest link. +fn chain_ceiling(chain: &[SignedGrant]) -> Option { + chain.iter().filter_map(|sg| ceiling_of(&sg.grant)).fold(None, |acc, fc| { + Some(match acc { + Some(parent) => parent.attenuate(&fc), + None => fc, + }) + }) +} + /// Whether every caveat carried inside the grant holds (SPEC §4.9). /// /// `cost_uj` is `None` in the two-phase metered path ([`Runtime::authorize`]), @@ -140,7 +239,7 @@ impl Runtime { }; // Validity checks 1–6, in the same fixed order as `decide`. - if let Some(r) = self.check(frame, sg, caller, now) { + if let Some(r) = self.check(frame, sg, caller, now, None) { return deny(r); } // 6b. Caveats (§4.9). @@ -220,6 +319,25 @@ impl Runtime { } } + /// Forbid values carrying any origin in `mask` from reaching `resource` (SPEC + /// §4.10) — the flow analogue of [`trust_root`](Self::trust_root). An + /// enforcement-point control that applies regardless of any grant's ceiling and + /// cannot be attenuated away. + pub fn forbid_flow(&mut self, resource: ContentId, mask: Origins) { + self.taint_floor.forbidden.insert(resource, mask); + } + + /// Forbid `mask` on every resource without its own floor rule — a fail-closed + /// default, off unless set. + pub fn forbid_flow_by_default(&mut self, mask: Origins) { + self.taint_floor.default_forbidden = mask; + } + + /// Joules spent against a grant's tainted sub-budget. + pub fn spent_tainted(&self, grant_id: &ContentId) -> u64 { + *self.spent_tainted_uj.get(grant_id).unwrap_or(&0) + } + /// Decide a frame against the grant it references and a metered `cost`, for /// `caller` at time `now`. On `Allow`, debits the cost from the grant's /// budget. Pure and deterministic — the checks run in a fixed order and the @@ -242,7 +360,7 @@ impl Runtime { }; // Validity checks 1–6, in fixed order (first failure names the reason). - if let Some(r) = self.check(frame, sg, caller, now) { + if let Some(r) = self.check(frame, sg, caller, now, None) { return deny(r); } // 6b. Caveats carried inside the grant (§4.9) — the open attenuation axis. @@ -269,7 +387,14 @@ impl Runtime { /// Validity checks 1–6 shared by [`decide`](Self::decide) and /// [`authorize`](Self::authorize): everything except the cost-specific /// budget test. `None` means the frame may proceed to a budget decision. - fn check(&self, frame: &Frame, sg: &SignedGrant, caller: Did, now: u64) -> Option { + fn check( + &self, + frame: &Frame, + sg: &SignedGrant, + caller: Did, + now: u64, + facts: Option<&FlowFacts>, + ) -> Option { let g = &sg.grant; let gid = g.id(); // 1. Untrusted output can never exercise control — anti-tool-poisoning. @@ -307,6 +432,17 @@ impl Runtime { if !g.capability.actions.iter().any(|a| a == &frame.action) { return Some(DenyReason::ActionNotGranted); } + // 6c. Flow (SPEC §4.10): the argument's propagated provenance must sit at + // or below the grant's ceiling, and the resource's taint floor must not + // refuse its origins. Placed here on purpose — after capability (the + // ceiling lives inside an authenticated grant, so judging flow before + // the signature would seal a verdict over attacker-chosen policy) and + // before caveats and budget (a tainted call must cost zero joules, the + // same invariant tool-poisoning denial holds). A ceiling-free grant + // with no facts reaches here unchanged. + if let Some(r) = flow_gate(g, &self.taint_floor, &frame.resource, facts) { + return Some(r); + } None } @@ -321,7 +457,7 @@ impl Runtime { let g = &sg.grant; let gid = g.id(); let remaining = g.budget_uj.saturating_sub(self.spent(&gid)); - if let Some(r) = self.check(frame, sg, caller, now) + if let Some(r) = self.check(frame, sg, caller, now, None) .or_else(|| caveats_unsatisfied(g, frame, caller, None, now)) { return Decision { @@ -372,6 +508,109 @@ impl Runtime { } } + /// The flow-aware [`authorize`](Self::authorize): the same two-phase gate, but + /// the flow facts are checked (check 6c) so a tainted argument is denied + /// `TaintedFlow` **before** any budget is touched, and a call already at its + /// tainted sub-budget is denied `TaintBudgetExceeded` before the tool runs. + /// `authorize` itself delegates here conceptually with `facts = None`; a + /// gateway that computes provenance calls this with real facts. + pub fn authorize_flow( + &mut self, + frame: &Frame, + sg: &SignedGrant, + caller: Did, + now: u64, + facts: &FlowFacts, + ) -> Decision { + let g = &sg.grant; + let gid = g.id(); + let remaining = g.budget_uj.saturating_sub(self.spent(&gid)); + let deny = |r: DenyReason| Decision { + verdict: Verdict::Deny(r), + cost: Energy::measured(0), + budget_remaining_uj: remaining, + }; + if let Some(r) = self + .check(frame, sg, caller, now, Some(facts)) + .or_else(|| caveats_unsatisfied(g, frame, caller, None, now)) + { + return deny(r); + } + if remaining == 0 { + return Decision { + verdict: Verdict::Deny(DenyReason::BudgetExceeded), + cost: Energy::measured(0), + budget_remaining_uj: 0, + }; + } + // Tainted work gets the smaller wallet. Pre-checked at authorize (like the + // main budget's `remaining == 0` degradation) so a call that has exhausted + // its tainted sub-budget is denied before the upstream tool runs. + if !facts.is_bottom() { + if let Some(cap) = ceiling_of(g).and_then(|fc| fc.tainted_budget_uj) { + if self.spent_tainted(&gid) >= cap { + return deny(DenyReason::TaintBudgetExceeded); + } + } + } + Decision { verdict: Verdict::Allow, cost: Energy::measured(0), budget_remaining_uj: remaining } + } + + /// The flow-aware [`charge`](Self::charge): debit the measured tool joules as + /// usual, and — when the call was tainted — also accrue them against the + /// grant's tainted sub-budget, so the tighter wallet actually depletes. The + /// monitor's own evaluation cost (`facts.eval_uj`) is **not** folded in here; + /// it is reported and sealed separately so a call's energy keeps its meaning. + pub fn charge_flow(&mut self, grant: &Grant, measured: Energy, facts: &FlowFacts) -> Decision { + let decision = self.charge(grant, measured); + if !facts.is_bottom() { + let gid = grant.id(); + let accrued = self.spent_tainted(&gid).saturating_add(measured.uj()); + self.spent_tainted_uj.insert(gid, accrued); + } + decision + } + + /// The flow-aware [`decide`](Self::decide): the one-shot path with flow facts + /// checked at 6c. A tainted argument is denied `TaintedFlow` before the budget + /// test, so — like a tool-poisoning denial — it debits zero joules. + pub fn decide_flow( + &mut self, + frame: &Frame, + sg: &SignedGrant, + caller: Did, + cost: Energy, + now: u64, + facts: &FlowFacts, + ) -> Decision { + let g = &sg.grant; + let gid = g.id(); + let remaining = g.budget_uj.saturating_sub(self.spent(&gid)); + let deny = |r: DenyReason| Decision { + verdict: Verdict::Deny(r), + cost, + budget_remaining_uj: remaining, + }; + if let Some(r) = self.check(frame, sg, caller, now, Some(facts)) { + return deny(r); + } + if let Some(r) = caveats_unsatisfied(g, frame, caller, Some(cost.uj()), now) { + return deny(r); + } + let spent = self.spent(&gid); + let c = cost.uj(); + if spent.saturating_add(c) > g.budget_uj { + return deny(DenyReason::BudgetExceeded); + } + let new_spent = spent + c; + self.spent_uj.insert(gid, new_spent); + if !facts.is_bottom() { + let accrued = self.spent_tainted(&gid).saturating_add(c); + self.spent_tainted_uj.insert(gid, accrued); + } + Decision { verdict: Verdict::Allow, cost, budget_remaining_uj: g.budget_uj - new_spent } + } + /// Decide a frame against a full delegation chain, **root first, leaf /// last** — multi-hop enforcement (SPEC §4.1). /// @@ -389,6 +628,35 @@ impl Runtime { caller: Did, cost: Energy, now: u64, + ) -> Decision { + // No flow facts: a chain whose links carry a flow ceiling fails closed + // (the un-migrated path), exactly as `decide` does with `check(.., None)`. + self.decide_chained_inner(frame, chain, caller, cost, now, None) + } + + /// The flow-aware [`decide_chained`](Self::decide_chained): the chain path with + /// the argument's provenance checked against the chain's attenuated ceiling and + /// the resource's taint floor, denied before any link is debited. + pub fn decide_chained_flow( + &mut self, + frame: &Frame, + chain: &[SignedGrant], + caller: Did, + cost: Energy, + now: u64, + facts: &FlowFacts, + ) -> Decision { + self.decide_chained_inner(frame, chain, caller, cost, now, Some(facts)) + } + + fn decide_chained_inner( + &mut self, + frame: &Frame, + chain: &[SignedGrant], + caller: Did, + cost: Energy, + now: u64, + facts: Option<&FlowFacts>, ) -> Decision { let leaf = match chain.last() { Some(l) => l, @@ -445,6 +713,27 @@ impl Runtime { if !leaf.grant.capability.actions.iter().any(|a| a == &frame.action) { return deny(DenyReason::ActionNotGranted); } + // 6c (chain form): flow against the chain's attenuated ceiling and the + // resource's taint floor. The ceiling is the meet over every link, so a + // chain is at most as permissive as its strictest hop, and a facts-less + // call under a ceilinged chain fails closed — mirroring the leaf gate. + { + let chained = chain_ceiling(chain); + if let Some(f) = facts { + if self.taint_floor.refuses(&frame.resource, &f.label) { + return deny(DenyReason::TaintedFlow); + } + } + match (chained, facts) { + (None, _) => {} + (Some(_), None) => return deny(DenyReason::TaintedFlow), + (Some(ceiling), Some(f)) => { + if !ceiling.admits(&f.label) { + return deny(DenyReason::TaintedFlow); + } + } + } + } // Energy conservation: the cost must fit every link's remaining budget. let c = cost.uj(); for sg in chain { @@ -459,6 +748,14 @@ impl Runtime { let s = self.spent(&gid) + c; self.spent_uj.insert(gid, s); } + // A tainted chained call also depletes each link's tainted sub-budget. + if facts.map(|f| !f.is_bottom()).unwrap_or(false) { + for sg in chain { + let gid = sg.grant.id(); + let accrued = self.spent_tainted(&gid).saturating_add(c); + self.spent_tainted_uj.insert(gid, accrued); + } + } Decision { verdict: Verdict::Allow, cost, @@ -918,4 +1215,199 @@ mod tests { let f = leaf_frame(&chain_a, r); assert_eq!(rt.decide_chained(&f, &[], sub, Energy::measured(1), 10).verdict, Verdict::Deny(DenyReason::ChainBroken)); } + + // ── flow gate (SPEC §4.10, check 6c) ───────────────────────────────────── + + use jcp_schema::flow::{FlowCeiling, FlowFacts, Integrity, Label, Origin, Origins, Readers}; + use jcp_schema::Provenance; + + fn flow_grant(caveats: Vec) -> (SignedGrant, Did, ContentId) { + let issuer = sk(1); + let holder = sk(2); + let resource = ContentId::of_bytes(b"db"); + let g = Grant { + issuer: did(&issuer), + subject: did(&holder), + capability: Capability::new(resource, ["read"]), + budget_uj: 100, + budget_funds: None, + caveats, + expiry: 1000, + parent: None, + delegate_depth: None, + nonce: [0; 16], + }; + (issue(&issuer, g).unwrap(), did(&holder), resource) + } + + /// A tainted argument: untrusted, carrying `channel`'s origin. + fn tainted(channel: u8) -> FlowFacts { + FlowFacts { + label: Label { + integrity: Integrity::Untrusted, + origins: Origins::one(Origin::channel(ContentId::of_bytes(&[channel; 4]))), + readers: Readers::Public, + }, + witness_id: ContentId([0u8; 32]), + eval_uj: 5, + eval_prov: Provenance::ModelBased, + } + } + + /// A ceiling that admits only principal-clean, origin-free values. + fn principal_only() -> FlowCeiling { + FlowCeiling::new(Label { + integrity: Integrity::Principal, + origins: Origins::none(), + readers: Readers::Public, + }) + } + + /// A permissive ceiling that admits any label but has a small tainted budget. + fn permissive_with_tainted_budget(budget: u64) -> FlowCeiling { + FlowCeiling { ceiling: Label::top(), tainted_budget_uj: Some(budget) } + } + + #[test] + fn ceiling_free_grant_is_a_noop_for_any_facts() { + // Every grant that exists today is ceiling-free; it must behave identically + // whether facts are absent, clean, or tainted. + let (sg, holder, r) = flow_grant(Vec::new()); + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + assert!(rt.decide(&f, &sg, holder, Energy::measured(10), 10).is_allow()); + let mut rt = Runtime::new(); + assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &FlowFacts::bottom()).is_allow()); + let mut rt = Runtime::new(); + assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(3)).is_allow()); + } + + #[test] + fn a_bottom_flow_call_is_byte_identical_to_the_plain_path() { + let (sg, holder, r) = flow_grant(Vec::new()); + let f = frame(&sg, r, "read", Trust::Control); + let mut plain = Runtime::new(); + let mut flow = Runtime::new(); + assert_eq!( + plain.decide(&f, &sg, holder, Energy::measured(30), 10), + flow.decide_flow(&f, &sg, holder, Energy::measured(30), 10, &FlowFacts::bottom()), + ); + assert_eq!(plain.spent(&sg.grant.id()), flow.spent(&sg.grant.id())); + } + + #[test] + fn clean_facts_pass_a_ceiling() { + let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(principal_only())]); + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &FlowFacts::bottom()).is_allow()); + } + + #[test] + fn a_tainted_argument_is_denied_by_a_ceiling_with_zero_debit() { + // The core property: a value from an untrusted origin cannot exercise a + // principal-only grant, and the denial precedes the budget so it costs no + // joules — the same invariant tool poisoning holds. + let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(principal_only())]); + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + let d = rt.decide_flow(&f, &sg, holder, Energy::measured(30), 10, &tainted(7)); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::TaintedFlow)); + assert_eq!(rt.spent(&sg.grant.id()), 0, "a tainted deny debits nothing"); + } + + #[test] + fn a_ceiling_grant_on_the_old_path_fails_closed() { + // An un-migrated caller (no facts) handed a ceilinged grant is denied, never + // silently unenforced. + let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(principal_only())]); + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + assert_eq!( + rt.decide(&f, &sg, holder, Energy::measured(10), 10).verdict, + Verdict::Deny(DenyReason::TaintedFlow), + ); + assert_eq!(rt.spent(&sg.grant.id()), 0); + } + + #[test] + fn the_taint_floor_refuses_a_forbidden_origin_regardless_of_ceiling() { + // An enforcement-point refusal that no grant carries and none can widen. + let (sg, holder, r) = flow_grant(Vec::new()); // no ceiling at all + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + let forbidden = Origins::one(Origin::channel(ContentId::of_bytes(&[7u8; 4]))); + rt.forbid_flow(r, forbidden); + let d = rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(7)); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::TaintedFlow)); + // A value from a different origin is unaffected. + assert!(rt.decide_flow(&f, &sg, holder, Energy::measured(10), 10, &tainted(9)).is_allow()); + } + + #[test] + fn the_tainted_sub_budget_pre_checks_at_authorize() { + // A permissive ceiling admits the label, but the tighter tainted wallet + // denies once spent — before the upstream tool runs. + let (sg, holder, r) = flow_grant(vec![Caveat::FlowBelow(permissive_with_tainted_budget(20))]); + let f = frame(&sg, r, "read", Trust::Control); + let mut rt = Runtime::new(); + // First tainted call is admitted and depletes the tainted wallet. + assert!(rt.authorize_flow(&f, &sg, holder, 10, &tainted(3)).is_allow()); + rt.charge_flow(&sg.grant, Energy::measured(20), &tainted(3)); + assert_eq!(rt.spent_tainted(&sg.grant.id()), 20); + // The wallet is now spent: the next tainted call is refused before running. + let d = rt.authorize_flow(&f, &sg, holder, 10, &tainted(3)); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::TaintBudgetExceeded)); + // A clean call still passes — the sub-budget only bounds tainted work. + assert!(rt.authorize_flow(&f, &sg, holder, 10, &FlowFacts::bottom()).is_allow()); + } + + #[test] + fn charge_flow_only_depletes_the_tainted_wallet_for_tainted_calls() { + let (sg, _holder, _r) = flow_grant(Vec::new()); + let mut rt = Runtime::new(); + rt.charge_flow(&sg.grant, Energy::measured(15), &FlowFacts::bottom()); + assert_eq!(rt.spent_tainted(&sg.grant.id()), 0, "a clean call spends no tainted budget"); + rt.charge_flow(&sg.grant, Energy::measured(15), &tainted(1)); + assert_eq!(rt.spent_tainted(&sg.grant.id()), 15, "a tainted call does"); + } + + #[test] + fn a_chained_grant_gates_flow_and_fails_closed_without_facts() { + // A delegated grant carrying a ceiling: the plain chain path fails closed, + // and the flow path denies a tainted argument. + let issuer = sk(1); + let holder = sk(2); + let resource = ContentId::of_bytes(b"db"); + let root = Grant { + issuer: did(&issuer), + subject: did(&holder), + capability: Capability::new(resource, ["read"]), + budget_uj: 100, + budget_funds: None, + caveats: vec![Caveat::FlowBelow(principal_only())], + expiry: 1000, + parent: None, + delegate_depth: None, + nonce: [0; 16], + }; + let root_sg = issue(&issuer, root).unwrap(); + let f = frame(&root_sg, resource, "read", Trust::Control); + + let mut rt = Runtime::new(); + // Plain chain path: no facts under a ceiling ⇒ fail closed. + assert_eq!( + rt.decide_chained(&f, &[root_sg.clone()], did(&holder), Energy::measured(10), 10).verdict, + Verdict::Deny(DenyReason::TaintedFlow), + ); + // Flow chain path: a tainted argument is denied. + assert_eq!( + rt.decide_chained_flow(&f, &[root_sg.clone()], did(&holder), Energy::measured(10), 10, &tainted(7)).verdict, + Verdict::Deny(DenyReason::TaintedFlow), + ); + // A clean argument passes. + assert!(rt + .decide_chained_flow(&f, &[root_sg], did(&holder), Energy::measured(10), 10, &FlowFacts::bottom()) + .is_allow()); + } } diff --git a/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md b/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md new file mode 100644 index 000000000..d61c892b1 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-schema/FLOW_LATTICE_PLAN.md @@ -0,0 +1,572 @@ +# FL-2 / `jcp-flow` — flow-lattice provenance propagation design + +> Blueprint for upgrading JCP's one-bit `Trust::Control/Data` boundary into +> propagated provenance labels, sealed into JCR-1 receipts and the joule budget. +> Produced by a multi-agent design+adversarial-judging pass (4 designs × 3 attack +> lenses → synthesis), then verified against the real tree. +> +> **Status:** increment 1 of ~5 shipped — the flow lattice (`jcp-schema/src/flow.rs`, +> laws L1–L16). Remaining: witness/store/scan + L17–L20 (`jcp-flow`), the runtime +> gate (`jcp-runtime`), the `jcp/3` receipt seal (`jcp-receipt`), and the gateway +> Phase 0–4 relay (`jcp-gateway`). See §1's file list. + +--- + +Verified against the real tree: `jcp-flow` does not exist yet, the gateway depends on `jcp-schema/jcp-grant/jcp-runtime/jcp-receipt/jcp-bridge` (not `jcp-policy`), and the runtime signatures match the grounding exactly (`check` at :272, `authorize` :320, `charge` :354, `decide` :227, `decide_chained` :385). That confirms the two structural facts the plan must respect: the gateway seals only through `ReceiptLog`/jcp-2 and drives `authorize`→`charge`, never `decide` and never a `PolicyEngine`. + +Here is the synthesized plan. + +--- + +# FL-2 / `jcp-flow`: a sealed flow lattice with a replayable provenance witness + +**One-line thesis.** Keep FL-1's lattice-as-authorization core, but bind and seal the verdict on the path the gateway *actually runs* (`authorize`→`charge`→`ReceiptLog`, profile `jcp/3`), and make it replayable with PROV-1's content-addressed witness disclosed through a store — not through a `policy/1` sibling that has no emitter and not through a `store_root` that cannot be reopened. + +## 0. What was grafted, and every winner fatal-flaw resolved + +| Winner (FL-1) fatal flaw | Resolution in FL-2 | +|---|---| +| **F1** buzhash rolling hash is buggy; "Law L17" (39-byte carry guarantee) is false; short secrets below floor invisible | Replace the fragile rolling hash with an **exact `blake3` W-byte-window index** (TAS's mechanism, W=16, no rotate arithmetic to get wrong). Carry ≥ W bytes is *exactly* detectable. Add a **`SensitiveTerms` exact index** for short registered secrets (account numbers, recipients) so length-` u8 { self as u8 } + pub const fn weaker(self, o: Self) -> Self { if (self as u8) <= (o as u8) { self } else { o } } + pub const fn stronger(self, o: Self) -> Self { if (self as u8) >= (o as u8) { self } else { o } } +} +impl From for Integrity { // Data ↦ Untrusted, Control ↦ Principal + fn from(t: Trust) -> Self { match t { Trust::Control => Integrity::Principal, Trust::Data => Integrity::Untrusted } } +} + +/// Where a value came from: the CONTENT-ADDRESSED channel id (mcp_resource(server,tool)) +/// plus the principal inside it. Content-addressed, not string-named, so it is +/// forgery-resistant AND floor rules are authorable at startup from a known channel id. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Origin { pub channel: ContentId, + #[serde(skip_serializing_if="Option::is_none", default)] pub principal: Option } + +pub const MAX_ORIGINS: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all="kebab-case", try_from="OriginsWire")] +pub enum Origins { Set(BTreeSet), Any } // Any = saturation top; sound over-approx +// (norm(): |set|>MAX_ORIGINS ⇒ Any. `try_from` on the wire re-imposes norm so ⊑ stays antisymmetric.) +impl Origins { + pub fn join(&self, o:&Self)->Self; // union, saturating + pub fn meet(&self, o:&Self)->Self; // intersection; Any is identity (for ceilings) + pub fn leq(&self, o:&Self)->bool; // subset; _ ⊑ Any + pub fn intersects(&self, mask:&Self)->bool; // used by TaintFloor + pub fn none()->Self; pub fn one(o:Origin)->Self; pub fn is_empty(&self)->bool; +} + +/// Confidentiality half — PRESENT & SEALED, enforcement is v2 (see §7). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all="kebab-case")] +pub enum Readers { Public, Only(BTreeSet) } +impl Readers { pub fn join(&self,o:&Self)->Self /*intersection, Public=id*/; + pub fn meet(&self,o:&Self)->Self; pub fn leq(&self,o:&Self)->bool; + pub fn permits(&self, audience:&BTreeSet)->bool; } + +/// A point in the flow lattice. Private fields ⇒ equality is semantic ⇒ ⊑ antisymmetric. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Label { integrity: Integrity, origins: Origins, readers: Readers } +impl Label { + pub fn bottom()->Self; // (Principal, ∅, Public) ← neutral / pre-flow + pub fn top()->Self; // (Untrusted, Any, Only(∅)) ← unknown id resolves here + pub fn join(&self,o:&Self)->Self; // weaker integrity, ∪ origins, ∩ readers ← THE operator + pub fn meet(&self,o:&Self)->Self; // ceilings only + pub fn leq(&self,o:&Self)->bool; // rank≥ ∧ origins⊑ ∧ readers⊑ ← authorization + pub fn is_bottom(&self)->bool; + pub fn digest(&self)->ContentId; // Merkle root: canonical-CBOR-legal by construction + pub fn trust(&self)->Trust; // Untrusted↦Data else Control (retraction) + pub fn integrity(&self)->Integrity; pub fn origins(&self)->&Origins; pub fn readers(&self)->&Readers; +} +impl From for Label { /* (Integrity::from(t), none, Public) */ } +``` + +`crates/jcp-flow/src/witness.rs` — the replayable artifact (PROV-1's insight, made concrete): + +```rust +/// Evidence class of a taint edge — SEALED, so the receipt shows whether the +/// verdict rested on hard bytes or the conservative floor. +#[derive(Clone,Copy,Debug,PartialEq,Eq,PartialOrd,Ord,Serialize,Deserialize)] +pub enum EdgeKind { Witnessed, Declared, Ambient } + +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct Edge { + pub parent: ContentId, // the ancestor payload's content id (its store key) + pub kind: EdgeKind, + #[serde(skip_serializing_if="u64_is_zero", default)] pub witnessed_bytes: u64, + // For a Witnessed edge, the fingerprints proving overlap — a third party + // rechecks these against BOTH endpoints' window sets (subset test). + #[serde(skip_serializing_if="BTreeSet::is_empty", default)] pub grams: BTreeSet, +} + +/// The MINIMAL ancestor cut + its edges + the folded label. Content-addressed, +/// self-contained, offline-replayable. The receipt commits only `id()`. +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct Witness { + pub schema: alloc::string::String, // "flow/1" + pub params: ScanParams, // W, and (later) sensitive-term set digest — pinned for exact replay + pub subject: ContentId, // == frame.payload_hash of the decided call + pub label: Label, // the folded effective label + pub cut: alloc::vec::Vec, // each direct ancestor: (content id, label, anchor) + pub edges: alloc::vec::Vec, +} +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct CutNode { pub id: ContentId, pub label: Label, pub anchor: ContentId } // anchor = ReceiptLog head when admitted +impl Witness { pub fn id(&self)->ContentId { ContentId::of(self).expect("canonical") } } + +/// What the runtime consumes at gate time. Bottom ⇒ every existing path is byte-identical. +#[derive(Clone,Debug,PartialEq,Eq)] +pub struct FlowFacts { + pub label: Label, + pub witness_id: ContentId, // commitment; the Witness itself lives in the WitnessStore + pub eval_uj: u64, // monitor's own metered cost — reported, never folded into cost_uj + pub eval_prov: jcp_schema::Provenance, +} +impl FlowFacts { pub fn bottom()->Self; pub fn is_bottom(&self)->bool { self.label.is_bottom() } } +``` + +`crates/jcp-flow/src/store.rs` — session state + the **exact** carry detector (F1 fix): + +```rust +pub const SCAN_W: usize = 16; // detection floor = exactly W bytes (blake3, not buzhash) +#[derive(Clone,Copy,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct ScanParams { pub w: u16, pub sensitive_root: ContentId } // pinned in every Witness + +/// Every W-byte window, DOMAIN-SEPARATED blake3 → u64. No rolling state, no +/// rotate arithmetic (the FL-1 bug lived there). Same bytes ⇒ same set, always. +fn windows(bytes: &[u8], w: usize) -> BTreeSet { /* blake3::keyed_hash("jcp/flow/window/v1", &bytes[i..i+w]) truncated */ } + +pub struct FlowStore { + labels: BTreeMap, + windows: BTreeMap>, // fingerprint → admitted ids containing it + sensitive: BTreeMap, BTreeSet>, // short registered terms → source ids + order: alloc::vec::Vec, // FIFO eviction (deterministic; evicted ⇒ ⊤ ⇒ stricter) + high_water: Label, // monotone session floor for Ambient mode + head: ContentId, // ReceiptLog head at last admission (the Witness anchor) + cap: usize, params: ScanParams, +} +impl FlowStore { + /// Boundary: admit a tool RESULT. Label = channel origin (Untrusted) ⊔ args label. + /// Also registers any adapter-declared sensitive terms for this output. + pub fn admit_output(&mut self, bytes:&[u8], channel:ContentId, principal:Option, + args:&Label, readers:Readers, terms:&[&[u8]], anchor:ContentId) -> (ContentId, Label); + pub fn admit_principal(&mut self, bytes:&[u8], anchor:ContentId) -> (ContentId, Label); // ⊥ + pub fn admit_attested(&mut self, bytes:&[u8], channel:ContentId, signer:Did, anchor:ContentId) -> (ContentId, Label); + + /// THE propagation entry point. Fold over declared ∪ window-found ∪ sensitive-found ∪ (Ambient? high_water), + /// force ⊤ for any named-but-absent id, admit the payload, emit a self-contained Witness. + pub fn label_payload(&mut self, bytes:&[u8], declared:&BTreeSet, mode:Mode, anchor:ContentId) + -> (ContentId, Label, Witness); + pub fn get(&self, id:&ContentId)->Option<&Label>; pub fn head(&self)->ContentId; +} +#[derive(Clone,Copy,Debug,PartialEq,Eq)] pub enum Mode { Explicit, HighWater } // HighWater = coarse implicit-flow floor +``` + +`crates/jcp-flow/src/ceiling.rs`: + +```rust +/// A grant's flow ceiling — greatest label whose values may exercise the capability. +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct FlowCeiling { pub ceiling: Label, + #[serde(skip_serializing_if="Option::is_none", default)] pub tainted_budget_uj: Option } +impl FlowCeiling { + pub fn attenuate(&self, child:&FlowCeiling)->FlowCeiling; // meet on label, min on budget ⇒ amplification unrepresentable + pub fn effective_budget(&self, grant_uj:u64, l:&Label)->u64; +} +/// Endorsement — signed declassification (PROV-1). Bound to ONE exact (subject,label) pair. +#[derive(Clone,Debug,PartialEq,Eq,Serialize,Deserialize)] +pub struct Endorsement { pub endorser:Did, pub subject:ContentId, pub from_label:ContentId, + pub to_label:ContentId, pub sig:alloc::vec::Vec } +``` + +`crates/jcp-flow/src/replay.rs` — the actual deliverable (PROV-1's verifier): + +```rust +pub trait WitnessStore { fn get(&self, id:&ContentId) -> Option; } +pub struct ReplayReport { pub verified:usize, pub unjustified_edges:usize, pub ambient_only:usize } +pub enum ReplayError { Chain(usize), WitnessMismatch(ContentId), LabelMismatch(ContentId), + UnjustifiedEdge(ContentId), FoldMismatch(ContentId), Excised(ContentId), VerdictMismatch(ContentId) } +/// Re-verify a jcp/2+jcp/3 receipt stream against disclosed witnesses. See §4E. +pub fn replay(receipts:&[alloc::vec::Vec], store:&dyn WitnessStore, + steward:&str, pk:&[u8;32]) -> Result; +``` + +### 1b. `crates/jcp-schema/src/lib.rs` — two additive, encoding-preserving changes + +```rust +pub enum Caveat { /* …6 existing… */ + /// NEW (SPEC §4.9). Flow-dependent: holds()=>true; the real test is the runtime flow gate. + /// Additive to an externally-tagged enum ⇒ no grant that omits it moves its ContentId + /// (same property `absent_money_ceiling_does_not_change_the_encoding` pins for budget_funds). + FlowBelow(jcp_flow::FlowCeiling), +} +impl Caveat { pub fn is_flow_dependent(&self)->bool { matches!(self, Caveat::FlowBelow(_)) } } +// holds(): Caveat::FlowBelow(_) => true (never blocks the caveat path) + +pub enum DenyReason { /* …14 existing… */ + TaintedFlow, // label ⋢ ceiling, or resource floor refuses + TaintBudgetExceeded, // tainted sub-ceiling hit +} +// label(): "tainted-flow", "taint-budget-exceeded" + +pub struct Grant { /* … */ + #[serde(skip_serializing_if="Option::is_none", default)] pub tainted_budget_uj: Option, // omit-when-absent + /* … */ } +``` + +`Frame` is **not touched** (§5). `jcp-schema` gains a `jcp-flow` dep — acyclic (`jcp-flow` depends only on `jcp-schema`, so put `FlowCeiling` behind a `flow` feature *or* move `FlowCeiling`/`Label` into `jcp-schema` itself to avoid the cycle). **Decision: define `Label`/`Origins`/`Integrity`/`Readers`/`FlowCeiling` in `jcp-schema` (they are pure data types with no logic-heavy deps), and keep `FlowStore`/`Witness`/`replay`/scan in `jcp-flow`.** That removes the cycle cleanly and lets `Caveat::FlowBelow` reference the ceiling without a back-dep. + +### 1c. `crates/jcp-runtime/src/lib.rs` + +```rust +pub struct Runtime { /* …existing… */ + taint_floor: TaintFloor, // NEW — mirrors trust_roots (resource → forbidden origins) + spent_tainted_uj: HashMap, // NEW — mirrors spent_minor +} +pub struct TaintFloor { forbidden: BTreeMap, default_forbidden: Origins } +impl TaintFloor { fn refuses(&self, resource:&ContentId, l:&Label)->bool { + l.origins().intersects(self.forbidden.get(resource).unwrap_or(&self.default_forbidden)) } } +impl Runtime { + pub fn forbid_flow(&mut self, resource:ContentId, mask:Origins); // mirrors trust_root + pub fn forbid_flow_by_default(&mut self, mask:Origins); // fail-closed switch (off by default) + pub fn spent_tainted(&self, gid:&ContentId)->u64; + // flow-aware entry points; old ones delegate with facts=None (see §5). + pub fn authorize_flow(&mut self, frame:&Frame, sg:&SignedGrant, caller:Did, now:u64, facts:&FlowFacts)->Decision; + pub fn charge_flow(&mut self, grant:&Grant, measured:Energy, facts:&FlowFacts, flow_uj:u64)->Decision; + pub fn decide_flow(&mut self, frame:&Frame, sg:&SignedGrant, caller:Did, cost:Energy, now:u64, facts:&FlowFacts)->Decision; + pub fn decide_chained_flow(&mut self, frame:&Frame, chain:&[SignedGrant], caller:Did, cost:Energy, now:u64, facts:&FlowFacts)->Decision; +} +// check() gains one private param; it is private, so no public signature moves: +fn check(&self, frame:&Frame, sg:&SignedGrant, caller:Did, now:u64, facts:Option<&FlowFacts>) -> Option; +``` + +The gate helpers (private): + +```rust +fn ceiling_of(g:&Grant)->Option { + g.caveats.iter().fold(None,|a,c| match (a,c){ + (a,Caveat::FlowBelow(fc))=>Some(match a{Some(p)=>p.attenuate(fc),None=>fc.clone()}), (a,_)=>a }) +} +fn flow_gate(g:&Grant, floor:&TaintFloor, resource:&ContentId, facts:Option<&FlowFacts>) -> Option { + if let Some(f)=facts { if floor.refuses(resource,&f.label) { return Some(DenyReason::TaintedFlow); } } + match (ceiling_of(g), facts) { + (None,_) => None, // today's behaviour + (Some(_),None) => Some(DenyReason::TaintedFlow), // fail CLOSED + (Some(c),Some(f)) if f.label.leq(&c.ceiling) => None, + (Some(_),Some(_)) => Some(DenyReason::TaintedFlow), + } +} +``` + +### 1d. `crates/jcp-receipt/src/lib.rs` + +```rust +pub const PROFILE_FLOW: &str = "jcp/3"; +#[derive(Clone,Debug,PartialEq,Eq,Default)] +pub struct FlowSeal { pub facts: Option, pub ceiling: Option } // ceiling digest, if any +fn profile_for(seal:&FlowSeal)->&'static str { // jcp/2 unless the label is non-⊥ + match &seal.facts { Some(f) if !f.is_bottom() => PROFILE_FLOW, _ => PROFILE } } + +pub fn to_jcr1_wire_flow(frame:&Frame, grant:&Grant, decision:&Decision, steward:&str, seal:&FlowSeal)->Value; +pub fn seal_flow(frame:&Frame, grant:&Grant, decision:&Decision, sk:&SigningKey, steward:&str, seal:&FlowSeal)->Vec; +// verify() gains ONE arm covering {PROFILE, PROFILE_FLOW}; PROFILE_V1 unchanged. +// ReceiptLog::append_flow(frame, grant, decision, seal) — the gateway's new seal call. +``` + +### 1e. `crates/jcp-policy/src/lib.rs` — optional second gate + +```rust +#[non_exhaustive] pub enum Match { /* …4 existing… */ + FlowAbove(Label), OriginIn(BTreeSet), IntegrityAtMost(Integrity), + NotReadableBy(Did), // v2 (readers), present now + Caller(Vec), // closes the noted gap: ctx.caller is sealed but unmatchable +} +#[non_exhaustive] pub struct PolicyContext<'a> { /* …4 existing… */ pub flow: Option<&'a FlowFacts> } +// flow==None ⇒ every flow variant matches false ⇒ byte-identical policy/1 receipts. +``` + +--- + +## 2. The propagation rule as unit-testable algebraic law + +**The rule, once.** For a payload `p` with witness inputs `W(p)`: + +``` +label(p) = ⨆{ label(i) : i ∈ W(p) ∩ dom(store) } ⊔ (⊤ if W(p) ⊄ dom(store) else ⊥) +``` + +folded **eagerly at admission**, stored under `ContentId::of_bytes(p)`, and accompanied by a self-contained `Witness`. `W(p) = declared ∪ window-found ∪ sensitive-found ∪ (high_water if Mode::HighWater)`. + +The deliverable is a `proptest` suite over arbitrary `Label`s and a conformance vector set (so any FL-2 impl is checkable against the *laws*, not just fixtures): + +| Law | Statement | Note | +|---|---|---| +| L1 idempotent | `a ⊔ a = a` | | +| L2 commutative | `a ⊔ b = b ⊔ a` | replay need not reproduce fold order | +| L3 associative | `(a⊔b)⊔c = a⊔(b⊔c)` | | +| L4 identity | `a ⊔ ⊥ = a` | ⊥ = degeneracy hook | +| L5 absorbing top | `a ⊔ ⊤ = ⊤` | unknown-id fail-closed | +| L6 order/join | `a ⊑ b ⟺ a⊔b = b` | | +| L7 partial order | reflexive ∧ transitive ∧ **antisymmetric** | antisymmetry needs `Origins::norm`; hence private fields + `try_from` on the wire | +| L8 monotone join | `a⊑a' ∧ b⊑b' ⇒ a⊔b ⊑ a'⊔b'` | | +| L9 no-laundering | `∀ u ∈ ancestors(v). label(u) ⊑ label(v)` | taint never decreases | +| L10 confluence | store labels invariant under any valid topological admission order | eager ≡ lazy fold | +| L11 downward-closed allow | `allow(l) ∧ l'⊑l ⇒ allow(l')` | | +| L12 delegation | `⨅` over a longer chain `⊑` `⨅` over any prefix | amplification unrepresentable (meet) | +| L13 saturation sound | `Set(s) ⊑ Any` | saturation only denies more | +| L14 determinism | `digest(l)` pure; no `HashMap` on any path | | +| L15 refinement | `allow_FL2 ⊆ allow_pre` | never allows what JCP denies today | +| L16 retraction | `Label::from(t).trust() == t`; `l⊑m ⇒ trust(l) ≤ trust(m)` (Data) -> Option { + // 1 frame.trust==Data → UntrustedControl (UNMOVED, still first) + // 1b revocations → Revoked + // 1c root → UntrustedRoot + // 2 grant binding … 3 subject … 4 verify … 5 expiry … 6 capability (all unmoved) + // 6c FLOW — structural property of the arguments, decided before caveats & budget: + if let Some(r) = flow_gate(&sg.grant, &self.taint_floor, &frame.resource, facts) { return Some(r); } + None +} +``` + +Why exactly here — each pinned to something in the code: + +1. **After check 1, never before.** `frame.trust==Data ⇒ UntrustedControl` stays byte-first, so conformance pattern 3 (`jcp-conformance/src/lib.rs:953–1008`) and `gen_vectors.rs`'s `deny_f` still name `untrusted-control`. The one-bit **boundary** tag and the **propagated** label are different questions with different reasons. +2. **After check 4 (`verify`).** The ceiling lives inside the grant; judging flow against an unauthenticated grant would seal a provenance verdict over attacker-chosen policy. `check` places 6c well past the signature. +3. **Before caveats (6b in the callers) and before budget (7), therefore before any debit.** A tainted call must cost **zero joules** — the same invariant `tool_poisoning_is_refused_structurally` asserts (`spent==0`). Because 6c is inside `check`, and every caller runs `check` before `caveats_unsatisfied` and before the budget test, `TaintedFlow` returns before `spent_uj.insert` on every path. +4. **`&self`-compatible, one insertion.** `check` is `&self` and shared by all three non-chained entry points; `facts` is *pre-computed by the gateway* and arrives as `Option<&FlowFacts>`, so no fold happens inside the runtime and no public signature moves. The fail-closed backstop `flow_gate(Some(_),None)=TaintedFlow` is the same code — a caller reaching the *old* `authorize` with a *new* ceiling grant is denied, never silently unenforced. + +Reason precedence note (honest): a call that violates *both* a plain caveat and the flow ceiling now reports `tainted-flow` rather than `caveat-unsatisfied`. This changes **no** existing vector (no published grant carries `FlowBelow`), and origin-of-data is the more security-relevant fact, so this is a deliberate, documented choice. + +`decide_chained` needs its own mirrored insertion because it re-implements the leaf checks inline and never calls `check`; there the ceiling is `chain_ceiling(chain) = ⨅` over every link, so a chain whose links disagree is *stricter* than the leaf — fail-closed by the meet, not by an extra branch. (Note: `decide_chained` still does not evaluate caveats — a pre-existing gap; the `TaintFloor` half *does* apply there, so a delegated call cannot escape the enforcement-point-wide refusal, but a self-attenuating `FlowBelow` on a chained grant is not honoured until that gap is fixed separately. Stated, not smuggled.) + +**Why not `charge`.** `charge` takes a bare `&Grant`, no frame/caller/clock, and debits *before* it decides. The flow label is fully known at authorize time (a property of arguments, not execution), so it binds at authorize. Only the tainted **sub-budget** touches `charge_flow` (a real accumulator debit, §4C). + +--- + +## 4. Binding the verdict into the receipt + joule budget, and third-party replay + +### 4A. Where it seals — `jcp/3`, on the real gateway path + +The gateway seals through `ReceiptLog::append` (jcp/2) today. FL-2 adds `ReceiptLog::append_flow(frame, grant, decision, seal)`. `profile_for(seal)` mints **`jcp/2` byte-for-byte when the label is ⊥**, and **`jcp/3` only when tainted**. `verify` gets one arm for `{PROFILE, PROFILE_FLOW}` recomputing the flow core; `to_jcr1_wire_v2` is retained by subtraction (the exact `jcp/1→jcp/2` precedent at `jcp-receipt/src/lib.rs:41`). + +The `jcp/3` flow block (emitted iff `!label.is_bottom()`; all text/uint/array — no bool/null/float, so `cbor_value` accepts it; key order irrelevant, `signed_core_bytes` sorts length-first): + +```json +"jcp_receipt": "jcp/3", +"flow": { + "label": "", + "integrity": "untrusted|derived|attested|principal", + "origins": ["", …] | "any", + "readers": "public" | ["did:key:z…", …], + "witness": "", // commitment; the artifact is disclosed via WitnessStore + "ceiling": "", + "verdict": "below" | "above", + "flow_uj": , // monitor's own metered cost — SEPARATE field + "flow_energy_provenance": "HwShunt|ModelBased|Estimator" +} +``` + +This is the answer to F5/F6: the verdict binds where the gateway actually seals (jcp/3 via `ReceiptLog`), not in a `policy/1` that has no emitter, and not as a forbidden field under an unchanged `jcp/2` tag. `cost_uj` keeps its exact meaning (tool energy); `flow_uj` is a sibling with its own provenance (F3 fix). + +`policy/1` receives the same fields **only for deployments that run a `PolicyEngine`** (additive, `verify` recovers fields, old bytes unchanged) — a second, optional gate, no longer the primary binding. + +### 4B. Consent binds to provenance (FlowDAG/PROV-1 graft) + +`context_digest` currently mixes `action ‖ 0x00 ‖ resource ‖ caller ‖ H(content)`. FL-2 appends `label.digest() ‖ witness_id` **only when the label is non-⊥**. Every approval ever minted was over a ⊥-flow call, so **every existing signature verifies bit-for-bit**; going forward, a human's consent is bound to *where the data came from*, so an approval cannot be replayed against the same call carrying differently-sourced arguments. `Endorsement` (signed declassification) is the one-way-ratchet escape hatch, bound to one exact `(subject, from_label, to_label)`. + +### 4C. The joule budget — three couplings, all on paths the gateway runs + +`Gateway::relay` becomes: + +```rust +// Phase 0 — provenance, metered by the same PowerMeter the upstream call uses. +let (facts, tm) = self.meter.measure(|| { + let declared = parse_declared(¶ms); // optional params._jcp_witness + let (_id, label, witness) = self.flow.label_payload(&args_bytes, &declared, self.mode, self.log.head()); + self.witness_store.put(witness.clone()); // disclosure store + FlowFacts { label, witness_id: witness.id(), eval_uj: 0, eval_prov: self.meter.provenance() } +}); +let facts = FlowFacts { eval_uj: tm.total_uj(), ..facts }; +let frame = mcp_call_frame(grant.id(), &self.server, tool, &args_bytes); + +// Phase 1 — authorize_flow: flow gate + tainted sub-ceiling pre-check, no debit. +let auth = self.runtime.authorize_flow(&frame, &sg, self.caller, self.now, &facts); +if !auth.is_allow() { + let seal = FlowSeal { facts: Some(facts), ceiling: ceiling_digest(&grant) }; + self.log.append_flow(&frame, &grant, &auth, &seal); // tainted denial sealed, ZERO joules debited + return Ok(Relayed::Denied { /* … */ }); +} + +// Phase 2 — metered upstream call (unchanged). +let (response, m) = self.meter.measure(|| up.call(line)); +let measured_uj = m.total_uj(); + +// Phase 3 — charge_flow: debit tool joules AND (if tainted) the tainted sub-accumulator. +let decision = self.runtime.charge_flow(&grant, Energy::measured(measured_uj), &facts, facts.eval_uj); +let seal = FlowSeal { facts: Some(facts), ceiling: ceiling_digest(&grant) }; +self.log.append_flow(&frame, &grant, &decision, &seal); + +// Phase 4 — the result becomes a labelled source (mcp_result_frame goes live, Trust::Data unchanged). +self.flow.admit_output(response.as_bytes(), mcp_resource(&self.server, tool), None, + &facts.label, readers_for(tool), &sensitive_terms_for(tool, &response), self.log.head()); +``` + +1. **Denied flows never debit.** 6c precedes budget/debit; `spent` unchanged on `TaintedFlow` — the exact `tool_poisoning_is_refused_structurally` assertion, extended to taint. +2. **Tainted work gets a smaller wallet — actually wired.** `authorize_flow` pre-checks `spent_tainted(gid) >= effective_tainted_budget` (mirroring the main budget's `remaining==0` degradation at authorize), denying `TaintBudgetExceeded` *before* the call; `charge_flow` debits `spent_tainted_uj` on the allow path. So "20 J clean / 200 mJ once you've touched the web" is a real, enforced control on the gateway path (F8 fix). `replay_spend` gains a `spent_tainted` reconstruction from the sealed `flow` block. +3. **The enforcer prices its own enforcement, honestly.** `flow_uj` (metered `PowerMeter` reading around `label_payload`, with its true `Provenance`) is **sealed as a separate field, not folded into `cost_uj`** (F3 + energy-tag fix). Default: reported, not debited. A deployment may opt to debit it against the tainted sub-budget via a flag; that choice is explicit, never a silent semantic change. + +### 4D. Fix the pre-existing `replay_spend` under-count (PROV-1 catch) + +`replay_spend`/`aggregate` filter `verdict=="allow"`, but `charge` debits then can return `Deny(CaveatUnsatisfied)` — so today the ledger under-counts by every cost-breached call. Fix both call sites to a debit predicate: `verdict=="allow" || deny_reason ∈ {"caveat-unsatisfied"}`. `tainted-flow` denies at authorize with zero debit, so it correctly stays in the non-debit branch. Ship this as its own commit (it is a real bug independent of FL-2). + +### 4E. Third-party replay — receipt **+ disclosed witness** (honest scope, F7 fix) + +`store_root` is gone. Replay is not "receipt-alone" (that was FL-1's false claim — `store_root` needed raw bytes). It is **receipt + the witnesses disclosed through a `WitnessStore`**, exactly the `jcp-cite::Citation` disclosure boundary: the receipt commits only `witness_id`; an entitled auditor fetches the `Witness` and rechecks it. Eight ordered checks (the ordering *is* the spec): + +1. **Chain** — `verify_log`: `seq[i]==i`, `prev[i]==H(env[i-1])`, genesis `0…0`. Profile-agnostic; a mixed `jcp/2`+`jcp/3` stream verifies end-to-end unchanged. +2. **Witness binding** — `Witness::id() == receipt.flow.witness`. +3. **Label binding** — `witness.label.digest() == receipt.flow.label`. +4. **Edge justification** — each `Witnessed` edge's `grams ⊆ windows(parent) ∩ windows(subject-cut-node)` (rechecked from the disclosed cut). `Declared` edges require a named artifact (a `jcp-cite` citation or `Endorsement`) to verify. `Ambient` edges are free-but-flagged. +5. **Fold correctness** — re-fold `witness.cut` over `witness.edges`; assert `== witness.label` (safe in any order by L2/L3). This is **L20**. +6. **Cut completeness (anti-excision)** — every `cut[j].anchor` must appear at a *strictly earlier* index in the already-verified chain. Dropping the tainting read breaks the chain at that point; step 1 catches the excision and step 6 catches reordering. *You cannot remove the untrusted read without breaking the chain.* +7. **Verdict determinism** — re-evaluate `witness.label.leq(&ceiling)` against the sealed `flow.ceiling`; assert `== receipt.flow.verdict`. Pure, no clock, no LLM. +8. **Energy reconciliation** — `Σ flow_uj` and `Σ` tainted debits reconcile against the corrected `replay_spend`. + +`ReplayReport` separately counts `unjustified_edges` and `ambient_only` verdicts, so an auditor can grade how much of a verdict rested on hard bytes versus the conservative floor — the on-wire evidence/floor distinction grafted from PROV-1/FlowDAG. + +**Honest limit stated inline:** step 4 rechecks *consistency* (grams ⊆ the disclosed cut's own window sets), not *truth* against the original bytes, unless the `WitnessStore` also discloses boundary bytes to the entitled auditor (a second disclosure tier). Against a fully-adversarial prover that under-populates a cut node's evidence, truth-verification requires disclosing the boundary bytes; that tier is available but off by default (it widens the disclosure surface). This is the PROV-1-security-judge concern, addressed by making the tier explicit rather than pretending consistency = truth. + +--- + +## 5. Backward-compatibility for the one-bit `Trust::Control/Data` path + +The one-bit path is a **fixed point**, enforced by construction, not by care. + +1. **`Frame` untouched.** It has no `skip_serializing_if` and no `impl` — any field is a hard wire break. FL-2 keys labels by `ContentId` (already how a frame names its payload) in a `Gateway.flow: FlowStore` side-store. Frame content ids, `Caveat::PayloadIs` bindings, every cross-language verifier: bit-unaffected. +2. **`Trust` embeds and retracts (L16).** `From for Integrity`: `Control↦Principal`, `Data↦Untrusted`; `Label::trust()`: `Untrusted↦Data`, else `Control`. `Label::from(t).trust()==t`, and `l⊑m ⇒ trust(l)≤trust(m)` under `Data` (`None` on legacy, exactly as `payload_hash: None` documents for `jcp/1`). `published_v1_receipts_still_verify` and every `jcp/2` fixture unchanged. +7. **Refinement (L15).** 6c is inserted after all existing checks and can only return `Deny`. `allow_FL2 ⊆ allow_pre`: FL-2 never allows a call JCP denies today. Worst case is over-denial — the correct direction for a reference monitor. +8. **`jcp-policy` is the one honest semver-major** (`#[non_exhaustive]` on `Match`/`PolicyContext` + a `flow` field). `ctx.flow==None` ⇒ every flow variant matches false ⇒ no new denies, byte-identical `policy/1`. Do it in one release; `#[non_exhaustive]` now makes it the last such break. `Match::matches` is private, so widening it is free. + +--- + +## 6. Concrete test list + +**Lattice laws (proptest + conformance vectors):** `l1_idempotent` … `l20_witness_refold`, one test each per §2, shipped as vectors in `jcp-conformance`. + +**Carry detector (F1 regression):** +- `carry_at_least_w_bytes_is_found` — a ≥16-byte verbatim run yields a `Witnessed` edge (L17). Directly replaces FL-1's broken buzhash guarantee. +- `blake3_window_index_is_platform_independent` — same bytes ⇒ same fingerprint set on two arches. +- `registered_sensitive_term_of_any_length_is_found` — an 11-char SSN registered by an adapter yields a `Witnessed` edge (L19). +- `sub_w_unregistered_carry_is_NOT_found` — a 12-byte unregistered token produces `⊥` from the scan alone; **documents the residual** so it can never be mistaken for a guarantee. + +**Compatibility (F2/F3/F5/F6):** +- `bottom_flow_call_is_byte_identical_to_pre_fl2` — clean frame ⇒ `jcp/2` bytes, COSE envelope, `prev`, and `context_digest` identical to today. (F3: because `flow_uj` is jcp/3-only, `cost_uj` is unmoved.) +- `ceiling_free_grant_is_a_noop_for_any_facts` — the compat law, over `None`/`Some(bottom)`/`Some(tainted)`. +- `ceiling_grant_on_old_authorize_fails_closed` — the fail-closed law. +- `pre_fl2_binary_rejects_flowbelow_grant` — serde parse failure ⇒ deny. +- `published_v1_and_v2_receipts_still_verify` — mixed `jcp/1`+`jcp/2`+`jcp/3` chain verifies end-to-end. +- `absent_tainted_budget_does_not_change_grant_id`. + +**Budget couplings (F8):** +- `tainted_flow_deny_debits_zero` — the `tool_poisoning` assertion, for `TaintedFlow`. +- `tainted_sub_ceiling_pre_checks_at_authorize` — `spent_tainted >= cap` ⇒ `TaintBudgetExceeded` before the upstream call. +- `charge_flow_debits_spent_tainted` — the accumulator actually accrues on the allow path (kills TAS's "accumulator stays 0" defect). +- `flow_uj_sealed_separately_with_own_provenance` — `cost_uj` unchanged; `flow_uj`/`flow_energy_provenance` present. + +**Replay (F7):** +- `replay_refolds_witness_to_sealed_label` (L20). +- `excised_untrusted_read_breaks_the_chain` — remove the admission ⇒ `ReplayError::Chain`/`Excised` (cut-completeness). +- `unjustified_witnessed_edge_is_rejected` — grams not ⊆ endpoints ⇒ `UnjustifiedEdge`. +- `ambient_only_verdict_is_flagged_in_report` — evidence-vs-floor grading. +- `mixed_profile_stream_replays` — jcp/2 and jcp/3 interleaved. + +**The multi-tool-call attack the security judges constructed (named `read_summarize_send_exfil`):** +The judges' canonical attack across the four designs is the *derived-from-untrusted-read exfiltration*: an agent reads an attacker-controlled source, then egresses attacker-chosen data two calls later, and `ContentContains` structurally cannot express the deny. FL-2's positive test: +- `read_email_then_send_email_denies_on_verbatim_carry`: + 1. `checkpoint`/principal turn at ⊥. + 2. `read_email` (attacker sender) → `admit_output` labels the result `(Untrusted, {Origin{channel=mcp_resource("mail","read_email"), principal=attacker}}, Public)`, indexes its windows, registers the exfil URL as a sensitive term. + 3. `summarize` (clean tool) → its args carry ≥16 verbatim bytes of the email → `label_payload` finds a `Witnessed` edge to the read; the summary is admitted tainted. + 4. `send_email(to=attacker, body=)` under a grant with `Caveat::FlowBelow{ ceiling: (integrity=Principal, origins=∅, readers=Public) }` → `send_email`'s args reach `Origin{mail.read_email, attacker}`, `label ⋢ ceiling`, 6c returns `TaintedFlow`, **zero joules debited**, `jcp/3` seals the `flow` block naming the origin, and the disclosed `Witness` lets a third party re-derive the deny. Assert: verdict `above`, `spent==0`, `verify` round-trips, `replay` re-folds. +- `send_money_recipient_must_reach_principal` — the banking shape: a `FlowBelow` ceiling requiring the recipient origin to be `Principal`; a recipient derived from a tool output is denied. +- `laundering_defeats_witness_but_high_water_holds` — the *negative* companion: base64/paraphrase the body so no window matches; assert the scan alone returns `⊥` (documents the residual), then assert `Mode::HighWater` on the egress sink denies via the session floor. This test exists precisely to prevent overclaiming §7. +- `short_account_number_needs_registration` — a 12-digit account number copied verbatim is caught **only** when the banking adapter registered it as a sensitive term; without registration it is invisible. Documents the F1 residual as a test, not a footnote. + +--- + +## 7. What this still does not stop (honest residuals) + +1. **Semantic laundering.** Base64, translation, summary-in-other-words, "spell the address with spaces" — all defeat the window index by construction. Against those, soundness rests on (a) harness declaration, (b) registered sensitive terms, or (c) `Mode::HighWater`'s coarse session floor. A monitor at the tool-call boundary cannot see the model's derivation; CaMeL sidesteps this only by owning the interpreter. This is the NeuroTaint thesis and FL-2 concedes it. **Accepted residual.** +2. **Sub-`SCAN_W` unregistered secrets.** An account number (8–16 chars) or bare email that is *not* registered by a boundary adapter and is below the 16-byte window floor is invisible to the monitor's own scan. Registration (L19) and declaration close it when a tool knows a field is sensitive; the fully-general case needs a sensitivity oracle JCP does not have. **Accepted residual, tested (`sub_w_unregistered_carry_is_NOT_found`).** +3. **Confidentiality (Readers) enforcement is v2.** `Readers` has correct algebra and is sealed, but JCP has no ACL model, so `readers_for(tool)` defaults to `Public` and the readers component is inert everywhere until per-tool audience oracles exist. v1 is **integrity × origins** only. The PROV-1-security-judge's readers-exfil (a restricted secret egressing with a clean verdict) is *not* solved in v1; the hook (`Match::NotReadableBy`, `permits(audience)`) is wired so v2 is additive. **Scoped, not claimed to work.** +4. **Implicit flows beyond `HighWater`.** No AST/interpreter, so branch-condition and loop-iterable dependencies are invisible; `Mode::HighWater` is a blunt over-approximation (everything after the first untrusted read joins the session label) that collapses utility within a few calls. Sound, not precise. **Accepted; opt-in per sink.** +5. **Replay is receipt + disclosed witness, and step-4 is consistency not truth against a hostile prover** unless boundary bytes are disclosed (second tier, off by default). Stated inline in §4E. **Accepted, explicit tier.** +6. **`FlowFacts` is in-process and unsigned** — it travels as a Rust argument from gateway to runtime; nothing binds it into the `Frame`. Fine in-process; the moment JCP goes cross-process over `jcp-wire`, the facts (or the `witness_id`) must ride the frame or a signed sidecar — the `Frame` wire break FL-2 deliberately avoids for now. **Deferred, not eliminated.** +7. **Boundary labellers are TCB.** Which channel/principal produced a byte, and which fields are sensitive, is ordinary adapter code with ordinary bugs; one adapter that mislabels an attacker sender as `Attested` is a silent bypass. The lattice laws (L1–L20) prove taint never decreases, authorization is downward-closed, delegation is monotone, and the verdict is reproducible — **none of them says "prompt injection cannot succeed."** Stating soundness precisely is not the same as being secure, and FL-2 says so rather than letting the algebra imply otherwise. +8. **Inherited non-goals** (CaMeL §3.1 / AuthGraph): pure text-to-text manipulation with no tool call, same-source poisoning (the attacker poisons the *authorized* channel's backend so the value legitimately carries the expected label), "read the email and do what it says" as a legitimate user instruction, and the ROP-style approximation of a denied call out of many allowed ones. + +--- + +### Files to create / touch (all absolute) + +- **New:** `/Users/dcharlot/data-share/vibe-coding/open-standards/jcp/jcp-rs/crates/jcp-flow/{Cargo.toml,src/{lib.rs,store.rs,witness.rs,replay.rs}}` (store/witness/scan/replay). +- **Edit `jcp-schema`** `/…/crates/jcp-schema/src/lib.rs`: define `Label/Origins/Integrity/Readers/FlowCeiling/Endorsement` here (breaks the dep cycle), add `Caveat::FlowBelow`, `DenyReason::{TaintedFlow,TaintBudgetExceeded}`, `Grant.tainted_budget_uj`. +- **Edit `jcp-runtime`** `/…/crates/jcp-runtime/src/lib.rs`: `check` gains `facts`; add `taint_floor`, `spent_tainted_uj`, `flow_gate`/`ceiling_of`/`chain_ceiling`, `authorize_flow`/`charge_flow`/`decide_flow`/`decide_chained_flow`, `forbid_flow*`; mirror 6c in `decide_chained`; fix `replay_spend`/`aggregate` debit predicate. +- **Edit `jcp-receipt`** `/…/crates/jcp-receipt/src/lib.rs`: `PROFILE_FLOW="jcp/3"`, `FlowSeal`, `to_jcr1_wire_flow`/`seal_flow`/`ReceiptLog::append_flow`, `to_jcr1_wire_v2` retention, one `verify` arm, `ReceiptCore.flow: Option`. +- **Edit `jcp-gateway`** `/…/crates/jcp-gateway/src/lib.rs`: add `flow: FlowStore`, `witness_store`, `mode`; the Phase 0–4 relay above; add `jcp-flow` to its `Cargo.toml`. +- **Edit `jcp-policy`** `/…/crates/jcp-policy/src/lib.rs`: `#[non_exhaustive]` + new `Match` variants + `PolicyContext.flow` + `context_digest` provenance append. +- **Edit `jcp-bridge`** `/…/crates/jcp-bridge/src/lib.rs`: no signature change — `mcp_result_frame` (currently dead, `:54`) goes live from the gateway, still `Trust::Data`. diff --git a/jcp/jcp-rs/crates/jcp-schema/src/flow.rs b/jcp/jcp-rs/crates/jcp-schema/src/flow.rs new file mode 100644 index 000000000..38e2b8c65 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-schema/src/flow.rs @@ -0,0 +1,710 @@ +//! The flow lattice (SPEC §4.10) — propagated provenance labels. +//! +//! JCP's [`Trust`](crate::Trust) tag is a one-*bit* boundary marker: a frame is +//! principal `Control` or untrusted `Data`, and a `Data` frame may never exercise +//! a capability. That stops output from being *executed*, but it cannot express +//! "this argument was **derived** from an earlier untrusted read": the bit does +//! not propagate through the values a run computes. +//! +//! This module is the propagated form. A [`Label`] is a point in a lattice with +//! three independent axes: +//! +//! - **integrity** — how believable the value is, a total order from `Untrusted` +//! up to `Principal`; +//! - **origins** — the set of channels a value's bytes flowed from; +//! - **readers** — who may see it (present and sealed, but *enforcement* is a +//! later increment — see the crate SPEC; today it only rides along). +//! +//! The one join `⊔` folds a derived value's label from its inputs. The whole +//! point is the ordering `⊑`: a value may exercise a capability only when its +//! label sits at or below the grant's [`FlowCeiling`]. Everything downstream (the +//! runtime gate, the sealed verdict, replay) is built on the algebra proven here, +//! so this module carries the lattice **laws** as tests rather than leaving them +//! implicit. +//! +//! Direction, fixed once: `⊥` (bottom) is the *cleanest* label — `Principal` +//! integrity, no origins, `Public` readers — and `join` moves *up* toward more +//! taint. So `a ⊑ b` reads "a is at least as clean as b", and taint on a derived +//! value never decreases below any of its inputs (the no-laundering law). +//! +//! All collections are `BTree*`, never `Hash*`: a label is content-addressed and +//! sealed into a receipt, so iteration order must be deterministic across runs and +//! platforms. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{ContentId, Did, SchemaError, Trust}; + +/// The integrity axis: how believable a value is. A **total** order — higher is +/// more trusted. In the taint join two labels take the *weaker* (lower) integrity, +/// because a value derived from an untrusted input is no more believable than that +/// input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Integrity { + Untrusted, + Derived, + Attested, + Principal, +} + +impl Integrity { + /// The weaker (less believable) of the two — the taint join on this axis. + pub fn weaker(self, other: Self) -> Self { + if self <= other { + self + } else { + other + } + } + + /// The stronger (more believable) of the two — the meet, used when attenuating + /// a ceiling (a child ceiling may only *raise* the integrity it demands). + pub fn stronger(self, other: Self) -> Self { + if self >= other { + self + } else { + other + } + } +} + +/// Where a value's bytes came from: a **content-addressed** channel (the +/// `mcp_resource(server, tool)` id), optionally narrowed to the principal that +/// spoke on it. Content-addressed rather than string-named so an origin cannot be +/// forged by naming, and so a resource-keyed taint floor can be authored against a +/// known channel id at startup. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Origin { + pub channel: ContentId, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub principal: Option, +} + +impl Origin { + pub fn channel(channel: ContentId) -> Self { + Origin { channel, principal: None } + } + pub fn from(channel: ContentId, principal: Did) -> Self { + Origin { channel, principal: Some(principal) } + } +} + +/// Above this many distinct origins a label saturates to [`Origins::Any`]. A +/// bound is needed so a long-running session's label cannot grow without limit; +/// saturation is *sound* because `Any` is the top of the axis and only ever denies +/// more (a ceiling that excludes some origin also excludes `Any`). +pub const MAX_ORIGINS: usize = 64; + +/// The origins axis. `Any` is the top: it stands for "could be from anywhere", a +/// conservative over-approximation a ceiling can never satisfy unless it, too, is +/// `Any`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", try_from = "OriginsWire")] +pub enum Origins { + Set(BTreeSet), + Any, +} + +/// Wire form of [`Origins`] — deserialised through [`Origins::normalize`] so an +/// over-large `Set` on the wire becomes `Any`, keeping `⊑` antisymmetric (two +/// distinct encodings of the top would break it). +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +enum OriginsWire { + Set(BTreeSet), + Any, +} + +impl TryFrom for Origins { + type Error = core::convert::Infallible; + fn try_from(wire: OriginsWire) -> Result { + Ok(match wire { + OriginsWire::Set(set) => Origins::Set(set).normalized(), + OriginsWire::Any => Origins::Any, + }) + } +} + +impl Origins { + pub fn none() -> Self { + Origins::Set(BTreeSet::new()) + } + + pub fn one(origin: Origin) -> Self { + let mut set = BTreeSet::new(); + set.insert(origin); + Origins::Set(set) + } + + pub fn is_empty(&self) -> bool { + matches!(self, Origins::Set(set) if set.is_empty()) + } + + /// Collapse an over-large set to `Any`. Idempotent; `Any` stays `Any`. + pub fn normalized(self) -> Self { + match self { + Origins::Set(set) if set.len() > MAX_ORIGINS => Origins::Any, + other => other, + } + } + + /// Join — union, saturating to `Any`. The taint direction: a value drawn from + /// two sources carries both. + pub fn join(&self, other: &Self) -> Self { + match (self, other) { + (Origins::Any, _) | (_, Origins::Any) => Origins::Any, + (Origins::Set(a), Origins::Set(b)) => { + Origins::Set(a.union(b).cloned().collect()).normalized() + } + } + } + + /// Meet — intersection; `Any` is the identity (used when attenuating a + /// ceiling, where `Any` means "no origin restriction"). + pub fn meet(&self, other: &Self) -> Self { + match (self, other) { + (Origins::Any, x) | (x, Origins::Any) => x.clone(), + (Origins::Set(a), Origins::Set(b)) => { + Origins::Set(a.intersection(b).cloned().collect()) + } + } + } + + /// `self ⊑ other`: subset, with everything below `Any`. + pub fn leq(&self, other: &Self) -> bool { + match (self, other) { + (_, Origins::Any) => true, + (Origins::Any, Origins::Set(_)) => false, + (Origins::Set(a), Origins::Set(b)) => a.is_subset(b), + } + } + + /// Does this label's origin set touch any origin in `mask`? The taint floor's + /// test: a resource can forbid values that carry a given origin. `Any` touches + /// every non-empty mask (the conservative reading of "could be from anywhere"). + pub fn intersects(&self, mask: &Self) -> bool { + match (self, mask) { + (Origins::Any, m) => !m.is_empty() || matches!(m, Origins::Any), + (s, Origins::Any) => !s.is_empty() || matches!(s, Origins::Any), + (Origins::Set(a), Origins::Set(b)) => a.intersection(b).next().is_some(), + } + } +} + +/// The confidentiality axis: who may see a value. `Public` is the bottom (any +/// reader). Present and sealed so the wire is stable, but audience *enforcement* +/// is a later increment; today it only propagates. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Readers { + Public, + Only(BTreeSet), +} + +impl Readers { + /// Join — audience *intersection* (a value combining two secrets is readable + /// only by those cleared for both), so fewer readers is more taint. `Public` + /// is the identity. + pub fn join(&self, other: &Self) -> Self { + match (self, other) { + (Readers::Public, x) | (x, Readers::Public) => x.clone(), + (Readers::Only(a), Readers::Only(b)) => { + Readers::Only(a.intersection(b).copied().collect()) + } + } + } + + /// Meet — audience *union*; `Public` absorbs (used to attenuate a ceiling's + /// reader bound). + pub fn meet(&self, other: &Self) -> Self { + match (self, other) { + (Readers::Public, _) | (_, Readers::Public) => Readers::Public, + (Readers::Only(a), Readers::Only(b)) => { + Readers::Only(a.union(b).copied().collect()) + } + } + } + + /// `self ⊑ other`: `other`'s audience is a subset of `self`'s (more readable is + /// cleaner). `Public` is below every `Only`. + pub fn leq(&self, other: &Self) -> bool { + match (self, other) { + (Readers::Public, _) => true, + (Readers::Only(_), Readers::Public) => false, + (Readers::Only(a), Readers::Only(b)) => b.is_subset(a), + } + } +} + +/// A point in the flow lattice: the propagated provenance of a value. `⊥` (see +/// [`Label::bottom`]) is the cleanest; [`Label::join`] moves toward more taint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Label { + pub integrity: Integrity, + pub origins: Origins, + pub readers: Readers, +} + +impl Label { + /// `⊥` — a principal-authored value from no channel, readable by all. The label + /// of every value in a run that has touched no untrusted source, so a clean run + /// is byte-identical to the pre-lattice world. + pub fn bottom() -> Self { + Label { integrity: Integrity::Principal, origins: Origins::none(), readers: Readers::Public } + } + + /// `⊤` — maximally tainted: untrusted, from anywhere, readable by nobody. The + /// saturation point; rarely constructed directly but the identity for meet. + pub fn top() -> Self { + Label { integrity: Integrity::Untrusted, origins: Origins::Any, readers: Readers::Only(BTreeSet::new()) } + } + + pub fn is_bottom(&self) -> bool { + *self == Label::bottom() + } + + /// Fold two labels — the taint join, component-wise. This is the operation + /// that propagates provenance from a value's inputs onto the value. + pub fn join(&self, other: &Self) -> Self { + Label { + integrity: self.integrity.weaker(other.integrity), + origins: self.origins.join(&other.origins), + readers: self.readers.join(&other.readers), + } + } + + /// The meet — greatest lower bound. Used to attenuate a ceiling so a child can + /// only make it stricter. + pub fn meet(&self, other: &Self) -> Self { + Label { + integrity: self.integrity.stronger(other.integrity), + origins: self.origins.meet(&other.origins), + readers: self.readers.meet(&other.readers), + } + } + + /// `self ⊑ other` — is this label at least as clean as `other`? A value may + /// exercise a capability exactly when its label `⊑` the ceiling. + pub fn leq(&self, other: &Self) -> bool { + self.integrity >= other.integrity + && self.origins.leq(&other.origins) + && self.readers.leq(&other.readers) + } + + /// The content address of this label — its identity in a witness and the value + /// sealed into a receipt. Pure and order-free because every field is `BTree`, + /// so the same label digests identically on any platform (the determinism law). + pub fn digest(&self) -> Result { + ContentId::of(self) + } + + /// Retract the propagated label to the one-bit [`Trust`] tag: a value is + /// `Data` exactly when its integrity is `Untrusted`, else `Control`. This is + /// the coarsening the pre-lattice world sees, and it is monotone — refining a + /// label never contradicts the bit. + pub fn trust(&self) -> Trust { + match self.integrity { + Integrity::Untrusted => Trust::Data, + _ => Trust::Control, + } + } +} + +impl From for Label { + /// Embed the one-bit tag as a label: `Control` is a principal-clean value, + /// `Data` an untrusted one — both from no specific channel. The inverse of + /// [`Label::trust`] on this range, so `Label::from(t).trust() == t`. + fn from(trust: Trust) -> Self { + match trust { + Trust::Control => Label::bottom(), + Trust::Data => Label { + integrity: Integrity::Untrusted, + origins: Origins::none(), + readers: Readers::Public, + }, + } + } +} + +/// A grant's flow ceiling: the greatest (most tainted) label whose values may +/// still exercise the capability, plus an optional smaller joule budget that +/// applies once a call is tainted at all. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowCeiling { + pub ceiling: Label, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tainted_budget_uj: Option, +} + +impl FlowCeiling { + pub fn new(ceiling: Label) -> Self { + FlowCeiling { ceiling, tainted_budget_uj: None } + } + + /// Attenuate for a delegation hop: the child ceiling is the **meet** of the two + /// (so it can only get stricter) and the tainted budget the **min**. Because + /// both moves are monotone-down, a child can never widen what a parent allowed + /// — capability amplification is unrepresentable, not merely rejected. + pub fn attenuate(&self, child: &FlowCeiling) -> FlowCeiling { + let tainted_budget_uj = match (self.tainted_budget_uj, child.tainted_budget_uj) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, b) => b, + }; + FlowCeiling { ceiling: self.ceiling.meet(&child.ceiling), tainted_budget_uj } + } + + /// Does a value with label `l` pass this ceiling? + pub fn admits(&self, l: &Label) -> bool { + l.leq(&self.ceiling) + } +} + +/// Fold a slice of labels — the join over all of them, starting from `⊥`. The +/// label of a value derived from many inputs. Order-free (join is commutative and +/// associative), which is what lets replay re-fold a witness in any order. +pub fn fold(labels: &[Label]) -> Label { + labels.iter().fold(Label::bottom(), |acc, l| acc.join(l)) +} + +/// What the runtime consumes at gate time: a value's folded [`Label`], a +/// commitment to the [witness](../../jcp_flow) that justifies it, and the monitor's +/// own metered cost of computing it (reported separately, never folded into the +/// call's energy). +/// +/// A `bottom` facts value means "provenance computed, and clean" — distinct from +/// *no* facts (`None` at the gate), which fails closed against a ceiling. This is +/// deliberately not a wire type: it travels in-process from the gateway to the +/// runtime, and what gets sealed is the label and `witness_id`, via the receipt. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FlowFacts { + pub label: Label, + pub witness_id: ContentId, + /// The monitor's own energy for computing the label (microjoules). + pub eval_uj: u64, + pub eval_prov: crate::Provenance, +} + +impl FlowFacts { + /// Clean facts — a value with no untrusted provenance and no monitor cost. + pub fn bottom() -> Self { + FlowFacts { + label: Label::bottom(), + witness_id: ContentId([0u8; 32]), + eval_uj: 0, + eval_prov: crate::Provenance::Estimator, + } + } + + /// Is this a clean (`⊥`) label? The gate treats bottom facts as "no ceiling + /// concern"; it is *not* the same as facts being absent. + pub fn is_bottom(&self) -> bool { + self.label.is_bottom() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cid(byte: u8) -> ContentId { + ContentId([byte; 32]) + } + fn did(byte: u8) -> Did { + Did([byte; 32]) + } + + /// A representative spread of labels covering every axis extreme, so the + /// exhaustive law checks below actually exercise the corners (Any, empty + /// origins, Public, restricted readers, every integrity rank). + fn sample_labels() -> Vec