From 27e277c820a88f9a37df2ffb40e643d08a07dc11 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 22 Jul 2026 02:09:29 -0400 Subject: [PATCH 1/2] jcp-flow: Compute propagated provenance from tool bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow lattice says what a label is; this crate computes one from real bytes. As a run reads tool outputs and derives new values, FlowStore tracks where each value's bytes came from and folds a derived value's label from the ancestors it was actually computed from — so authorization can be conditioned on where an argument came from, not merely on its literal content, the gap the one-bit Trust tag cannot express. Two properties make this a reference monitor rather than a heuristic. The carry detector is an exact blake3 window index, deliberately not a rolling hash: each W-byte window is hashed independently, so two values share a fingerprint exactly when they share a verbatim run, with no carry state to get wrong and identical fingerprints on every platform. And every verdict emits a self-contained, content-addressed Witness — the ancestor cut, the taint edges, and the folded label — so a third party re-folds it offline to re-derive the verdict rather than trusting it; a receipt commits only its id. label_payload folds over four sources, each an edge kind the witness records: verbatim carry the scan found (Witnessed, proven by grams), a registered sensitive term appearing verbatim (Witnessed, for secrets below the window floor), a caller declaration (Declared — or top, fail-closed, if that parent was evicted), and in HighWater mode the coarse session floor (Ambient). Every collection is BTree, so a witness content-addresses deterministically. Ten tests carry the laws L17-L20 and the residuals as executable claims: verbatim carry yields a witnessed edge; a sub-window unregistered token yields nothing (the residual, pinned so it is never mistaken for a guarantee); a registered term of any length is caught; verify_witness re-folds the cut and catches a label tampered to look clean; a declared-but-lost parent fails closed to top; and — the attack this exists to stop — a summary carrying an attacker email verbatim does not sit below a principal-only ceiling, while HighWater still taints a laundered payload the scan alone cannot see. Increment 2 of the FL-2 plan; the runtime gate, jcp/3 receipt seal, and gateway relay follow. The receipt-stream replay (the eight-check chain walk) lands with the receipt integration, where jcp/3 receipts exist to walk. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/Cargo.lock | 10 + jcp/jcp-rs/Cargo.toml | 1 + jcp/jcp-rs/crates/jcp-flow/Cargo.toml | 17 ++ jcp/jcp-rs/crates/jcp-flow/src/lib.rs | 235 ++++++++++++++ jcp/jcp-rs/crates/jcp-flow/src/store.rs | 353 ++++++++++++++++++++++ jcp/jcp-rs/crates/jcp-flow/src/witness.rs | 145 +++++++++ 6 files changed, 761 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-flow/Cargo.toml create mode 100644 jcp/jcp-rs/crates/jcp-flow/src/lib.rs create mode 100644 jcp/jcp-rs/crates/jcp-flow/src/store.rs create mode 100644 jcp/jcp-rs/crates/jcp-flow/src/witness.rs diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 2ba3fb10e..bcffd1d87 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -598,6 +598,16 @@ 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" diff --git a/jcp/jcp-rs/Cargo.toml b/jcp/jcp-rs/Cargo.toml index fcb6f59b4..6779a01fe 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-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(()) +} -- 2.54.0 (Apple Git-157) From 5b7e683f313565cba2cd551eb5673b9cdd1fce38 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 22 Jul 2026 07:54:58 -0400 Subject: [PATCH 2/2] jcp-gateway: Close the flow loop from tool output to sealed verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pieces exist; this wires them into the request path so provenance actually propagates across a session. The relay gains two phases around the existing authorize/meter/charge. Before authorizing, it folds the argument's flow label from the tool outputs admitted earlier this session — the scan finds any verbatim carry — and after a successful call it admits the result as a labelled, untrusted source, so a later call carrying its bytes is folded against it. Derived taint now outlives the single call that produced it, which is the whole point. Authorization runs against those facts (authorize_flow), so a tainted argument above the grant's ceiling is denied at the gate before the upstream is contacted and before any debit — zero joules, the tool-poisoning invariant extended to derived taint. The charge debits the tainted sub-budget too when the call was tainted, and the receipt is sealed with the flow verdict: jcp/3 when tainted, byte-identical jcp/2 when clean, so a non-flow deployment is unaffected. The monitor's own evaluation energy is metered on the same hardware meter and sealed as its own field, never as tool cost. The result frame is Trust::Data, so mcp_result_frame goes live from the gateway (it was written for exactly this and never called): an injected instruction in a tool result still cannot exercise a capability, and now the result's provenance also flows forward. The witnesses are retained by id so an auditor can fetch one and re-derive a sealed verdict offline. set_flow_ceiling attaches a FlowBelow ceiling to a tool by re-issuing its grant; enable_high_water turns on the coarse session floor; forbid_flow installs an enforcement-point taint floor for a tool. The capstone test is the attack the feature exists to stop, end to end: read an attacker email (allowed, its output admitted), then try to send a body carrying the secret verbatim — the send is denied tainted-flow at the gate, costs zero joules, seals a jcp/3 receipt whose flow block reads "above", and leaves a witness that verify_witness re-checks offline. A clean send still goes through, and the whole jcp/2 + jcp/3 log verifies as one chain. The four pre-existing gateway tests pass unchanged — a clean call is byte-identical to before. Final increment of the FL-2 plan. The branch merges the jcp-flow crate (#39) that the relay depends on; once the stack lands on main this rebases to the gateway diff alone. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/Cargo.lock | 1 + jcp/jcp-rs/crates/jcp-gateway/Cargo.toml | 1 + jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 233 ++++++++++++++++++++++- 3 files changed, 225 insertions(+), 10 deletions(-) diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index bcffd1d87..e30feef88 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -614,6 +614,7 @@ version = "0.1.0" dependencies = [ "ed25519-dalek", "jcp-bridge", + "jcp-flow", "jcp-grant", "jcp-receipt", "jcp-runtime", 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() + } } -- 2.54.0 (Apple Git-157)