From e5c19475250ae1011b2fa20d5a77ff40b43fba31 Mon Sep 17 00:00:00 2001 From: George-RD <180932462+George-RD@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:30:15 +0400 Subject: [PATCH] egress: add origin-closure stage blocking cross-identity data (#227) Add a new ordered stage to the pure disclosure core, AFTER the existing disclosure-coverage stage, that blocks an outbound item whose typed-identity ProvenanceOrigin lies outside the bound recipient's identity closure (a counterparty other than the recipient, or owner-internal non-public data to a stranger) unless a grant ProvenanceLabelAllowlist caveat authorizes that origin. Fail-closed on unresolved origin/recipient. Typed-field comparison only; no query text, no LLM judgment. New CrossIdentityBlock decision; DisclosurePolicyKey/DisclosurePolicy shape unchanged. Wired in the single shared enforce_disclosure_egress chokepoint: recipient resolved from the briefcase task_shape, authorized origins pre-resolved via effectively_allows_provenance_label. Both worker-requested and kernel-origin/proactive rated dispatch inherit the identical check (riding the #207 origin-symmetric plumbing) with no second ungated path; the dead EgressClassifier stays deleted and gate() stays pure. The cross-identity block records a reconstructible disclosure.cross_identity_blocked audit row and returns the generic worker denial. Tests: schema-level matrix over origin x recipient x sensitivity x caveat, plus dispatch-integration cases at the chokepoint. Adds threat-claim CLAIM-44 with named tests, closing Bell's "internal data to a stranger". Closes #227 --- crates/openspine-kernel/src/disclosure.rs | 12 +- .../openspine-kernel/src/disclosure/core.rs | 83 ++++++- .../disclosure_origin_closure_tests.rs | 204 ++++++++++++++++++ .../disclosure/disclosure_regression_tests.rs | 5 + .../src/disclosure/disclosure_tests.rs | 4 +- .../src/disclosure_policy.rs | 90 +++++++- .../src/disclosure_policy_tests.rs | 200 ++++++++++++++++- docs/threat-claims.md | 1 + 8 files changed, 587 insertions(+), 12 deletions(-) create mode 100644 crates/openspine-kernel/src/disclosure/disclosure_origin_closure_tests.rs diff --git a/crates/openspine-kernel/src/disclosure.rs b/crates/openspine-kernel/src/disclosure.rs index c3159136..1ed6420f 100644 --- a/crates/openspine-kernel/src/disclosure.rs +++ b/crates/openspine-kernel/src/disclosure.rs @@ -14,16 +14,18 @@ use std::collections::BTreeSet; use jiff::Timestamp; use openspine_schemas::action::ActionId; use openspine_schemas::artifact::{ArtifactRef, Lifecycle}; -use openspine_schemas::briefcase::{BriefcaseSection, VisibilityClass}; +use openspine_schemas::briefcase::{BriefcaseSection, CounterpartyRef, VisibilityClass}; use openspine_schemas::disclosure_policy::{ check_egress, generalize_query, ClassifiedBriefcaseItem, DisclosureCarveOut, DisclosureClass, DisclosurePolicy, DisclosurePolicyKey, DisclosureProvenance, OutboundQuery, - OwnerQuestionEscalation, PreparedQuery, PreparedQueryRef, + OwnerQuestionEscalation, PreparedQuery, PreparedQueryRef, RecipientIdentity, }; use openspine_schemas::egress::EgressClass; use openspine_schemas::escalation::EscalationEvent; use openspine_schemas::grant::TaskGrant; use openspine_schemas::identity::RelationshipKind; +use openspine_schemas::ids::IdentityRef; +use openspine_schemas::provenance::ProvenanceOrigin; use openspine_schemas::standing_rule::{BudgetWindow, StandingRuleManifest}; use serde_json::Value; use ulid::Ulid; @@ -49,6 +51,10 @@ pub(crate) enum DisclosureError { UnratedEgress(ActionId), UnclassifiedSection(String), BudgetExhausted(String), + /// D-174 / spec #220: the origin-closure stage blocked cross-identity data + /// whose typed origin lies outside the bound recipient's closure and is + /// unauthorized by any grant provenance caveat. + CrossIdentityBlocked, Store(StoreError), } @@ -68,6 +74,8 @@ pub(crate) use self::preparation::*; #[cfg(test)] mod disclosure_messaging_tests; #[cfg(test)] +mod disclosure_origin_closure_tests; +#[cfg(test)] mod disclosure_regression_tests; #[cfg(test)] mod disclosure_tests; diff --git a/crates/openspine-kernel/src/disclosure/core.rs b/crates/openspine-kernel/src/disclosure/core.rs index b8dca21c..2e89515f 100644 --- a/crates/openspine-kernel/src/disclosure/core.rs +++ b/crates/openspine-kernel/src/disclosure/core.rs @@ -54,11 +54,48 @@ pub(crate) async fn enforce_disclosure_egress( ) -> Result { let egress_class = trusted_egress_class(&request.action_id) .ok_or_else(|| DisclosureError::UnratedEgress(request.action_id.clone()))?; + // Resolve the typed recipient this grant's task is bound to reach from the + // kernel-owned briefcase (mirrors the `is_counterparty_erased` precedent). + // An unresolved counterparty — or no briefcase — is fail-closed: the origin + // closure then matches nothing. Resolved uniformly here so every dispatch + // origin (worker-requested and kernel-origin/proactive) inherits the + // identical check through this one chokepoint — no second ungated path. + let recipient = match state + .store + .find_briefcase(grant.id) + .map_err(DisclosureError::Store)? + { + Some(briefcase) => match briefcase.task_shape.counterparty { + CounterpartyRef::Bound { identity_id, .. } => RecipientIdentity::Counterparty { + identity: IdentityRef::from(identity_id), + }, + CounterpartyRef::Unresolved { .. } => RecipientIdentity::Unresolved, + }, + None => RecipientIdentity::Unresolved, + }; + // Pre-resolve the grant-authorized origin set (D-174 widening). The pure + // core stays grant-free and only tests membership. The v1 owner grant + // carries no `ProvenanceLabelAllowlist` caveat, so every present origin is + // authorized and the closure is dormant; a worker sub-grant's empty caveat + // authorizes none, closing the origin set strictly. + let mut authorized_origins: Vec = Vec::new(); + for item in &request.provenance.items { + if let Some(origin) = &item.origin { + if !authorized_origins.contains(origin) + && openspine_schemas::grant_chain::effectively_allows_provenance_label( + grant, origin, + ) + { + authorized_origins.push(origin.clone()); + } + } + } let query = OutboundQuery::from_private_context( &request.raw_query, &request.sensitive_terms, egress_class, request.provenance, + recipient, ); let now = Timestamp::now(); let all_policies = state @@ -135,7 +172,7 @@ pub(crate) async fn enforce_disclosure_egress( } let blocked_query_digest = openspine_schemas::digest::digest_of_bytes(query.generalized_query.as_bytes()); - match check_egress(request.relationship, query, &policies) { + match check_egress(request.relationship, query, &policies, &authorized_origins) { openspine_schemas::disclosure_policy::DisclosureGateDecision::Allow { query } => { Ok(EnforcedDisclosure { query, @@ -171,6 +208,50 @@ pub(crate) async fn enforce_disclosure_egress( .map_err(DisclosureError::Store)?; Err(DisclosureError::Blocked(escalation)) } + openspine_schemas::disclosure_policy::DisclosureGateDecision::CrossIdentityBlock { + origin, + recipient, + disclosure_class, + egress_class: blocked_egress, + } => { + cancel_reservations(&state.store, &reservations); + // Auditor (D-174): the block is reconstructible from typed origin + + // sensitivity + recipient + egress class + relationship. Detail is + // kernel-side only; the worker sees the generic denial mapped in + // api/actions.rs, never these internals. + let detail = format!( + "cross-identity egress blocked: origin={origin:?} recipient={recipient:?} class={disclosure_class:?} egress={blocked_egress:?} relationship={:?}", + request.relationship + ); + state + .store + .append_audit( + "disclosure.cross_identity_blocked", + Some(&request.action_id), + None, + Some(&detail), + Some(grant.id), + &[], + &[], + ) + .map_err(DisclosureError::Store)?; + // Inform the owner (AD-133) but mint no disclosure pending question: + // unlike a coverage block, a cross-identity block is not resolved by + // a relationship/class "/disclosure allow" — widening an origin is a + // grant-caveat decision, never a runtime owner answer. + let event = EscalationEvent::owner_question( + grant.id, + format!( + "Blocked outbound {disclosure_class:?} data whose origin is outside the recipient's identity closure ({blocked_egress:?})." + ), + grant.thread_id.clone(), + now, + ); + route_escalation(state, grant, &event) + .await + .map_err(DisclosureError::Store)?; + Err(DisclosureError::CrossIdentityBlocked) + } } } diff --git a/crates/openspine-kernel/src/disclosure/disclosure_origin_closure_tests.rs b/crates/openspine-kernel/src/disclosure/disclosure_origin_closure_tests.rs new file mode 100644 index 00000000..7c68b230 --- /dev/null +++ b/crates/openspine-kernel/src/disclosure/disclosure_origin_closure_tests.rs @@ -0,0 +1,204 @@ +//! Origin-closure (D-174 / spec #220) dispatch-integration regression tests. +//! +//! These exercise the closure at `enforce_disclosure_egress` — the single +//! connector-agnostic chokepoint EVERY dispatch origin funnels through +//! (worker-requested via `mediate_and_dispatch_action`, kernel-origin/proactive +//! via `..._kernel_origin`). The origin-symmetry of that chokepoint — that both +//! origins reach it with no second ungated path — is proven in +//! `api/disclosure_origin_tests`; the origin-closure stage lives inside the same +//! function, so it is enforced identically regardless of dispatch origin. +//! +//! The stage runs ONLY after disclosure coverage passes, so each case seeds a +//! covering (Client, Internal, Search) policy first, then varies origin × +//! recipient × grant caveat. The closure is dormant on the v1 single-owner +//! owner grant (which carries no `ProvenanceLabelAllowlist` caveat, so every +//! origin is authorized) and strict once a grant carries a narrowing caveat — +//! exactly the shape a counterparty-scoped worker sub-grant adopts (user +//! story 10). The worker-facing fixed generic denial is covered by +//! `disclosure_regression_tests::worker_denial_has_no_debug_leak`; here we +//! assert the kernel-side outcome plus the reconstructible audit row. + +use super::*; +use crate::api::dispatch_tests::{ + insert_bound_briefcase_with_sections, mint_grant_with_selection_token, +}; +use crate::test_support::fixtures::test_state_with_telegram; + +/// `insert_bound_briefcase_with_sections` binds this counterparty id as the +/// recipient, so the origin closure compares each item's origin against it. +const RECIPIENT_ID: u128 = 11; + +fn state() -> crate::pipeline::AppState { + test_state_with_telegram(crate::telegram::TelegramConnector::new( + "bottest-token".to_string(), + )) +} + +fn counterparty(id: u128) -> ProvenanceOrigin { + ProvenanceOrigin::Counterparty { + identity: IdentityRef::from(Ulid::from(id)), + } +} + +fn internal_item(origin: ProvenanceOrigin) -> ClassifiedBriefcaseItem { + ClassifiedBriefcaseItem { + item_ref: openspine_schemas::artifact::ArtifactRef { + digest: openspine_schemas::digest::digest_of_bytes(b"payload"), + schema_version: 1, + }, + disclosure_class: DisclosureClass::Internal, + origin: Some(origin), + } +} + +fn request(origin: ProvenanceOrigin) -> DisclosureRequest { + DisclosureRequest { + raw_query: "reply body".to_string(), + sensitive_terms: BTreeSet::new(), + action_id: ActionId::new("web.search"), + relationship: RelationshipKind::Client, + provenance: DisclosureProvenance { + items: vec![internal_item(origin)], + }, + } +} + +/// Append the D-174 narrowing caveat a counterparty-scoped sub-grant carries. +/// `enforce_disclosure_egress` reads the caveat chain (the gate verified the MAC +/// upstream), so appending to the sealed tip is faithful for this seam test. +fn narrow_to(grant: &mut TaskGrant, origins: Vec) { + grant + .chain + .last_mut() + .unwrap() + .added_caveats + .push(openspine_schemas::grant_chain::Caveat::ProvenanceLabelAllowlist { origins }); +} + +/// Seed a covering (Client, Internal, Search) policy and a grant whose briefcase +/// is bound to the recipient counterparty, so only the origin closure varies. +async fn seed(state: &crate::pipeline::AppState) -> TaskGrant { + let now = Timestamp::now(); + record_owner_answer( + &state.store, + DisclosurePolicyKey { + relationship: RelationshipKind::Client, + disclosure_class: DisclosureClass::Internal, + }, + EgressClass::Search, + vec![], + now, + ) + .unwrap(); + let (grant, _) = mint_grant_with_selection_token( + state, + &["web.search"], + now + std::time::Duration::from_secs(120), + ); + insert_bound_briefcase_with_sections(state, &grant, RelationshipKind::Client, vec![]); + grant +} + +/// A counterparty-X datum is blocked from egress to the bound recipient +/// counterparty Y once the grant carries the narrowing provenance caveat, even +/// though (Client, Internal, Search) coverage passes. The block is +/// reconstructible from the typed origin + sensitivity + recipient + egress +/// class recorded in the `disclosure.cross_identity_blocked` audit row (Auditor +/// story). Closes Bell's cross-counterparty leak. +#[tokio::test] +async fn counterparty_origin_blocked_from_a_different_recipient() { + let state = state(); + let mut grant = seed(&state).await; + narrow_to(&mut grant, vec![]); + let result = enforce_disclosure_egress(&state, &grant, request(counterparty(22))).await; + assert!(result.is_err(), "cross-counterparty egress must be blocked"); + assert_eq!( + state + .store + .count_audit_events_of_kind("disclosure.cross_identity_blocked") + .unwrap(), + 1, + "the block must record its reconstructible cross-identity audit row" + ); +} + +/// Bell "internal data to a stranger": owner-origin, non-public data cannot +/// reach a counterparty recipient under a narrowing grant (user story 3). +#[tokio::test] +async fn owner_origin_blocked_from_a_counterparty_recipient() { + let state = state(); + let mut grant = seed(&state).await; + narrow_to(&mut grant, vec![]); + let owner = ProvenanceOrigin::Owner { + principal: openspine_schemas::ids::PrincipalId::from(Ulid::new()), + }; + let result = enforce_disclosure_egress(&state, &grant, request(owner)).await; + assert!( + result.is_err(), + "owner-internal data to a stranger must block" + ); + assert_eq!( + state + .store + .count_audit_events_of_kind("disclosure.cross_identity_blocked") + .unwrap(), + 1 + ); +} + +/// A counterparty's own datum reaches that same bound counterparty recipient. +#[tokio::test] +async fn counterparty_origin_reaches_its_own_recipient() { + let state = state(); + let mut grant = seed(&state).await; + narrow_to(&mut grant, vec![]); + let result = + enforce_disclosure_egress(&state, &grant, request(counterparty(RECIPIENT_ID))).await; + assert!(result.is_ok(), "same-identity egress must be allowed"); + assert_eq!( + state + .store + .count_audit_events_of_kind("disclosure.cross_identity_blocked") + .unwrap(), + 0 + ); +} + +/// The #226 widening path: a `ProvenanceLabelAllowlist` caveat that names +/// counterparty X lets X's datum egress to a different recipient. +#[tokio::test] +async fn an_authorizing_caveat_lets_a_named_origin_egress() { + let state = state(); + let mut grant = seed(&state).await; + narrow_to(&mut grant, vec![counterparty(22)]); + let result = enforce_disclosure_egress(&state, &grant, request(counterparty(22))).await; + assert!(result.is_ok(), "an authorized origin must egress"); + assert_eq!( + state + .store + .count_audit_events_of_kind("disclosure.cross_identity_blocked") + .unwrap(), + 0 + ); +} + +/// The v1 single-owner owner grant carries no narrowing caveat, so every origin +/// is authorized and the closure is dormant — a cross-identity send is NOT +/// over-blocked, preserving Lyra's owner-directed flows. +#[tokio::test] +async fn owner_grant_without_a_caveat_does_not_over_block() { + let state = state(); + let grant = seed(&state).await; + let result = enforce_disclosure_egress(&state, &grant, request(counterparty(22))).await; + assert!( + result.is_ok(), + "the un-narrowed owner grant authorizes every origin" + ); + assert_eq!( + state + .store + .count_audit_events_of_kind("disclosure.cross_identity_blocked") + .unwrap(), + 0 + ); +} diff --git a/crates/openspine-kernel/src/disclosure/disclosure_regression_tests.rs b/crates/openspine-kernel/src/disclosure/disclosure_regression_tests.rs index 448fc1ae..0ba1815f 100644 --- a/crates/openspine-kernel/src/disclosure/disclosure_regression_tests.rs +++ b/crates/openspine-kernel/src/disclosure/disclosure_regression_tests.rs @@ -101,11 +101,13 @@ fn caller_omitted_class_is_still_enforced() { &BTreeSet::new(), EgressClass::Search, provenance, + openspine_schemas::disclosure_policy::RecipientIdentity::Unresolved, ); let decision = openspine_schemas::disclosure_policy::check_egress( RelationshipKind::Client, query, &policies, + &[], ); match decision { openspine_schemas::disclosure_policy::DisclosureGateDecision::Block { escalation } => { @@ -114,6 +116,9 @@ fn caller_omitted_class_is_still_enforced() { openspine_schemas::disclosure_policy::DisclosureGateDecision::Allow { .. } => { panic!("uncovered Sensitive class must block despite covered Internal") } + openspine_schemas::disclosure_policy::DisclosureGateDecision::CrossIdentityBlock { + .. + } => panic!("coverage must block the uncovered Sensitive class before the origin closure"), } } diff --git a/crates/openspine-kernel/src/disclosure/disclosure_tests.rs b/crates/openspine-kernel/src/disclosure/disclosure_tests.rs index 529023d7..d2177ea5 100644 --- a/crates/openspine-kernel/src/disclosure/disclosure_tests.rs +++ b/crates/openspine-kernel/src/disclosure/disclosure_tests.rs @@ -18,7 +18,7 @@ fn provenance(class: DisclosureClass) -> DisclosureProvenance { schema_version: 1, }, disclosure_class: class, - origin: None, + origin: Some(ProvenanceOrigin::System {}), }], } } @@ -100,8 +100,10 @@ fn uncovered_egress_blocks_and_produces_owner_question() { &BTreeSet::from(["condition X".to_string()]), EgressClass::Search, provenance(DisclosureClass::Sensitive), + RecipientIdentity::Unresolved, ), &state.store.load_disclosure_policies().unwrap(), + &[], ); let openspine_schemas::disclosure_policy::DisclosureGateDecision::Block { escalation } = decision diff --git a/crates/openspine-schemas/src/disclosure_policy.rs b/crates/openspine-schemas/src/disclosure_policy.rs index 9597cce0..f30e76de 100644 --- a/crates/openspine-schemas/src/disclosure_policy.rs +++ b/crates/openspine-schemas/src/disclosure_policy.rs @@ -15,7 +15,7 @@ use crate::digest::{digest_of_bytes, Digest}; use crate::egress::EgressClass; use crate::event::DataClassification; use crate::identity::RelationshipKind; -use crate::ids::ArtifactId; +use crate::ids::{ArtifactId, IdentityRef, PrincipalId}; use crate::provenance::ProvenanceOrigin; /// Disclosure sensitivity carried by a classified briefcase item. @@ -226,6 +226,24 @@ pub struct DisclosureCarveOut { pub query_shape: Digest, } +/// The typed identity a prepared outbound query is bound to reach (D-174 / +/// spec #220). The origin-closure stage in [`check_egress`] compares each +/// classified item's [`ProvenanceOrigin`] against this recipient; data whose +/// origin lies outside the recipient's identity closure is blocked unless a +/// grant caveat authorizes it. `Unresolved` is fail-closed: it matches no +/// origin, so an unresolved recipient can never receive counterparty- or +/// owner-origin data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecipientIdentity { + /// The owner principal (#197 / AD-146). + Owner { principal: PrincipalId }, + /// A specific counterparty identity. + Counterparty { identity: IdentityRef }, + /// No recipient identity could be resolved — most-restrictive, matches + /// nothing. + Unresolved, +} + /// One outbound query. `generalized_query` is the only text eligible for /// transport; `raw_query` remains local to the caller and is never serialized. #[derive(Debug, Clone, PartialEq, Eq)] @@ -233,6 +251,8 @@ pub struct OutboundQuery { pub generalized_query: String, pub egress_class: EgressClass, pub provenance: DisclosureProvenance, + /// The typed identity this query is bound to reach (D-174 / spec #220). + pub recipient: RecipientIdentity, } impl OutboundQuery { @@ -244,11 +264,13 @@ impl OutboundQuery { sensitive_terms: &BTreeSet, egress_class: EgressClass, provenance: DisclosureProvenance, + recipient: RecipientIdentity, ) -> Self { Self { generalized_query: generalize_query(raw_query, sensitive_terms), egress_class, provenance, + recipient, } } @@ -281,8 +303,41 @@ pub struct OwnerQuestionEscalation { /// The deterministic disclosure gate result. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DisclosureGateDecision { - Allow { query: OutboundQuery }, - Block { escalation: OwnerQuestionEscalation }, + Allow { + query: OutboundQuery, + }, + Block { + escalation: OwnerQuestionEscalation, + }, + /// D-174 / spec #220: the origin-closure stage blocked an item whose typed + /// identity origin lies outside the bound recipient's closure and is not + /// authorized by any grant provenance caveat. Distinct from the coverage + /// `Block` so the kernel audits and routes it separately and the auditor + /// can reconstruct the decision from origin + sensitivity + recipient + + /// egress class. + CrossIdentityBlock { + origin: Option, + recipient: RecipientIdentity, + disclosure_class: DisclosureClass, + egress_class: EgressClass, + }, +} + +/// Whether a classified item's typed-identity `origin` may reach `recipient` +/// under the D-174 origin closure. System-origin data is kernel-generated and +/// never a cross-identity disclosure, so it reaches any recipient; owner and +/// counterparty origins reach only the same typed identity. An `Unresolved` +/// recipient matches nothing (fail closed). +fn origin_reaches_recipient(origin: &ProvenanceOrigin, recipient: &RecipientIdentity) -> bool { + match origin { + ProvenanceOrigin::System {} => true, + ProvenanceOrigin::Owner { principal } => { + matches!(recipient, RecipientIdentity::Owner { principal: to } if principal == to) + } + ProvenanceOrigin::Counterparty { identity } => { + matches!(recipient, RecipientIdentity::Counterparty { identity: to } if identity == to) + } + } } /// Check every classified provenance class against the relationship-scoped @@ -292,7 +347,10 @@ pub fn check_egress( relationship: RelationshipKind, query: OutboundQuery, policies: &[DisclosurePolicy], + authorized_origins: &[ProvenanceOrigin], ) -> DisclosureGateDecision { + // Stage 1 — relationship-scoped disclosure coverage. Sensitivity comes + // exclusively from immutable provenance, never the generalized text. for class in query.provenance.classes() { if class.requires_policy() && !policies.iter().any(|policy| { @@ -317,6 +375,32 @@ pub fn check_egress( }; } } + // Stage 2 (D-174 / spec #220) — origin-vs-recipient closure. Runs ONLY + // after coverage passes. An item egresses to the bound recipient only when + // its typed-identity origin is within the recipient's closure (system, or + // the same identity) or a grant caveat authorizes the origin. An + // unresolved origin is most-restrictive and fails closed. Comparison is + // typed-field only — never query text, never an LLM judgment. + for item in &query.provenance.items { + if !item.disclosure_class.requires_policy() { + continue; + } + let reaches = match &item.origin { + Some(origin) => { + origin_reaches_recipient(origin, &query.recipient) + || authorized_origins.contains(origin) + } + None => false, + }; + if !reaches { + return DisclosureGateDecision::CrossIdentityBlock { + origin: item.origin.clone(), + recipient: query.recipient.clone(), + disclosure_class: item.disclosure_class, + egress_class: query.egress_class, + }; + } + } DisclosureGateDecision::Allow { query } } diff --git a/crates/openspine-schemas/src/disclosure_policy_tests.rs b/crates/openspine-schemas/src/disclosure_policy_tests.rs index b1f65da4..1eb01a56 100644 --- a/crates/openspine-schemas/src/disclosure_policy_tests.rs +++ b/crates/openspine-schemas/src/disclosure_policy_tests.rs @@ -27,8 +27,11 @@ fn query(class: DisclosureClass) -> OutboundQuery { &BTreeSet::from(["condition X".to_string()]), EgressClass::Search, DisclosureProvenance { - items: vec![item(class)], + // System origin so the coverage-focused cases below are unaffected + // by the origin closure (system data reaches any recipient). + items: vec![item_with_origin(class, Some(ProvenanceOrigin::System {}))], }, + RecipientIdentity::Unresolved, ) } @@ -61,6 +64,7 @@ fn uncovered_disclosure_class_blocks_and_produces_owner_question_escalation() { RelationshipKind::Client, query(DisclosureClass::Sensitive), &[], + &[], ); let DisclosureGateDecision::Block { escalation } = decision else { panic!("uncovered sensitive egress must block"); @@ -78,7 +82,7 @@ fn coverage_uses_provenance_even_when_generalized_text_is_public() { let mut outbound = query(DisclosureClass::Private); outbound.generalized_query = "public research topic".to_string(); assert!(matches!( - check_egress(RelationshipKind::Client, outbound, &[]), + check_egress(RelationshipKind::Client, outbound, &[], &[]), DisclosureGateDecision::Block { .. } )); } @@ -89,7 +93,8 @@ fn active_relationship_and_class_policy_allows_covered_egress() { check_egress( RelationshipKind::Client, query(DisclosureClass::Private), - &[policy(DisclosureClass::Private)] + &[policy(DisclosureClass::Private)], + &[], ), DisclosureGateDecision::Allow { .. } )); @@ -101,7 +106,8 @@ fn public_context_does_not_require_relationship_policy() { check_egress( RelationshipKind::Vendor, query(DisclosureClass::Public), - &[] + &[], + &[], ), DisclosureGateDecision::Allow { .. } )); @@ -119,7 +125,8 @@ fn carve_out_extends_covered_egress_without_new_policy() { check_egress( RelationshipKind::Client, query(DisclosureClass::Private), - &[covered] + &[covered], + &[], ), DisclosureGateDecision::Allow { .. } )); @@ -266,3 +273,186 @@ fn data_classification_maps_unknown_to_sensitive() { DisclosureClass::Sensitive ); } + +fn cp_origin(n: u128) -> ProvenanceOrigin { + ProvenanceOrigin::Counterparty { + identity: crate::ids::IdentityRef::from(Ulid::from(n)), + } +} + +fn cp_recipient(n: u128) -> RecipientIdentity { + RecipientIdentity::Counterparty { + identity: crate::ids::IdentityRef::from(Ulid::from(n)), + } +} + +fn owner_origin() -> ProvenanceOrigin { + ProvenanceOrigin::Owner { + principal: crate::ids::PrincipalId::from(Ulid::from(7_u128)), + } +} + +fn owner_recipient() -> RecipientIdentity { + RecipientIdentity::Owner { + principal: crate::ids::PrincipalId::from(Ulid::from(7_u128)), + } +} + +/// One classified item of `class` and `origin`, bound to `recipient`, over a +/// covered egress class — so the origin closure is what decides the outcome. +fn closure_query( + origin: Option, + recipient: RecipientIdentity, + class: DisclosureClass, +) -> OutboundQuery { + OutboundQuery::from_private_context( + "hello", + &BTreeSet::new(), + EgressClass::Search, + DisclosureProvenance { + items: vec![item_with_origin(class, origin)], + }, + recipient, + ) +} + +/// D-174 / spec #220: counterparty X's datum bound for counterparty Y is +/// blocked at the origin closure even though (Client, Internal, Search) is +/// covered — the cross-counterparty leak Bell's "internal data to a stranger" +/// failure mode names. +#[test] +fn cross_counterparty_origin_is_blocked_by_the_closure() { + let decision = check_egress( + RelationshipKind::Client, + closure_query( + Some(cp_origin(1)), + cp_recipient(2), + DisclosureClass::Internal, + ), + &[policy(DisclosureClass::Internal)], + &[], + ); + assert!(matches!( + decision, + DisclosureGateDecision::CrossIdentityBlock { .. } + )); +} + +/// A counterparty's own datum reaches that same counterparty recipient. +#[test] +fn same_counterparty_origin_reaches_its_recipient() { + let decision = check_egress( + RelationshipKind::Client, + closure_query( + Some(cp_origin(1)), + cp_recipient(1), + DisclosureClass::Internal, + ), + &[policy(DisclosureClass::Internal)], + &[], + ); + assert!(matches!(decision, DisclosureGateDecision::Allow { .. })); +} + +/// Owner-origin, non-public data cannot reach a counterparty (stranger) +/// recipient without an authorizing caveat — "internal data to a stranger" +/// closed by omission (user story 3). +#[test] +fn owner_origin_is_blocked_to_a_counterparty_recipient() { + let decision = check_egress( + RelationshipKind::Client, + closure_query( + Some(owner_origin()), + cp_recipient(2), + DisclosureClass::Internal, + ), + &[policy(DisclosureClass::Internal)], + &[], + ); + assert!(matches!( + decision, + DisclosureGateDecision::CrossIdentityBlock { .. } + )); +} + +/// Owner and system origins both reach the owner recipient. +#[test] +fn owner_and_system_origins_reach_the_owner_recipient() { + for origin in [owner_origin(), ProvenanceOrigin::System {}] { + let decision = check_egress( + RelationshipKind::Client, + closure_query(Some(origin), owner_recipient(), DisclosureClass::Internal), + &[policy(DisclosureClass::Internal)], + &[], + ); + assert!(matches!(decision, DisclosureGateDecision::Allow { .. })); + } +} + +/// System-origin data is kernel-generated, never a cross-identity disclosure, +/// so it reaches any recipient. +#[test] +fn system_origin_reaches_any_recipient() { + let decision = check_egress( + RelationshipKind::Client, + closure_query( + Some(ProvenanceOrigin::System {}), + cp_recipient(2), + DisclosureClass::Internal, + ), + &[policy(DisclosureClass::Internal)], + &[], + ); + assert!(matches!(decision, DisclosureGateDecision::Allow { .. })); +} + +/// A grant caveat that authorizes counterparty X's origin widens the closure: +/// X's datum may then egress to a different recipient (the #226 widening path). +#[test] +fn an_authorizing_caveat_widens_the_closure_for_the_named_origin() { + let decision = check_egress( + RelationshipKind::Client, + closure_query( + Some(cp_origin(1)), + cp_recipient(2), + DisclosureClass::Internal, + ), + &[policy(DisclosureClass::Internal)], + &[cp_origin(1)], + ); + assert!(matches!(decision, DisclosureGateDecision::Allow { .. })); +} + +/// The origin closure runs ONLY after coverage passes: with no covering +/// policy the coverage stage blocks first, so a cross-identity item is never +/// masked by (nor reaches) the closure. +#[test] +fn the_closure_runs_only_after_coverage_passes() { + let decision = check_egress( + RelationshipKind::Client, + closure_query( + Some(cp_origin(1)), + cp_recipient(2), + DisclosureClass::Internal, + ), + &[], + &[], + ); + assert!(matches!(decision, DisclosureGateDecision::Block { .. })); +} + +/// Fail closed: an unresolved (`None`) origin is most-restrictive and blocks +/// at the closure, never treated as safe to send (user story 7). +#[test] +fn an_unresolved_origin_fails_closed_at_the_closure() { + let decision = check_egress( + RelationshipKind::Client, + closure_query(None, cp_recipient(2), DisclosureClass::Internal), + &[policy(DisclosureClass::Internal)], + &[], + ); + assert!(matches!( + decision, + DisclosureGateDecision::CrossIdentityBlock { origin: None, .. } + )); +} diff --git a/docs/threat-claims.md b/docs/threat-claims.md index 8298f904..ef937ffa 100644 --- a/docs/threat-claims.md +++ b/docs/threat-claims.md @@ -54,6 +54,7 @@ exist in the workspace. | CLAIM-41 | The capability-derived tool catalog is recomputed fresh per request from `(grant, action_catalog)` with no persisted session store or cross-turn cache: a higher-authority grant and an attenuated one project different catalogs, and the higher catalog is identical across fetches and unaffected by an interleaved lower-grant fetch | `test: catalog_is_recomputed_per_grant_with_no_shared_session_state` | | CLAIM-42 | Structural absence is attenuation; `gate()` is the sole enforcement: an action absent from a grant's projected catalog is still denied by `gate()` when POSTed to `/v1/actions`, recorded as an `action.gated` deny audit — the catalog is advisory, never an enforcement mechanism | `test: action_absent_from_catalog_is_still_denied_by_gate` | | CLAIM-43 | External-egress disclosure enforcement is origin-symmetric: a rated messaging-send whose (relationship, disclosure class) is uncovered blocks and raises `OwnerQuestion` identically whether dispatched worker-origin (`ActionOrigin::Shell`), kernel-origin (`ActionOrigin::Kernel`), or via the proactive headless lane with no principal present — no dispatch origin is a second, ungated path for autonomous outbound content (spec #204 story 7, #207). The standing-rule timer redispatch path funnels through the same worker-origin shared entrypoint, so its disclosure hook is the one proven for `ActionOrigin::Shell`. The one hand-rolled kernel-origin path (`notify_owner_with_digest`) carries no gated egress precisely because its action (`owner.notify`) stays unrated and owner-facing. | `test: kernel_origin_rated_dispatch_blocks_and_raises_owner_question` `test: proactive_headless_rated_dispatch_blocks_and_raises_owner_question` `test: disclosure_block_is_identical_across_worker_kernel_and_headless_origins` `test: owner_notify_is_unrated_and_owner_facing_so_the_notify_path_carries_no_gated_egress` | +| CLAIM-44 | External egress blocks cross-identity data deterministically (D-174 / spec #220): after the disclosure-coverage stage passes, an origin-closure stage blocks any outbound item whose typed-identity provenance origin lies outside the bound recipient's identity closure — a counterparty other than the recipient, or owner-internal (non-public) data to a stranger — unless a grant `ProvenanceLabelAllowlist` caveat authorizes that origin; an unresolved origin fails closed. The check is typed-field only (no query text, no LLM judgment) and lives in the single `enforce_disclosure_egress` chokepoint every dispatch origin shares (worker-requested and kernel-origin/proactive, per CLAIM-43), so there is no second ungated path. The block is reconstructible by the Auditor from typed origin + sensitivity + recipient + egress class. This closes Bell's "internal data to a stranger" / cross-counterparty leak. | `test: cross_counterparty_origin_is_blocked_by_the_closure` `test: owner_origin_is_blocked_to_a_counterparty_recipient` `test: an_unresolved_origin_fails_closed_at_the_closure` `test: counterparty_origin_blocked_from_a_different_recipient` `test: owner_origin_blocked_from_a_counterparty_recipient` | - CLAIM-05 is enforced by `sandbox::tests::process_driver_clears_env_and_sets_only_two_vars`, which spawns a real child process and inspects its actual environment