diff --git a/crates/buzz-ifc/src/lib.rs b/crates/buzz-ifc/src/lib.rs index 892181f093..17940de9d1 100644 --- a/crates/buzz-ifc/src/lib.rs +++ b/crates/buzz-ifc/src/lib.rs @@ -6,10 +6,14 @@ //! and membership version, so a change produces a different key. //! //! The broker must verify events and membership before supplying [`DomainFacts`]. -//! It uses the resulting key to select an [`IfcSession`], then checks reads and -//! calls before executing them. Publications must pass [`IfcSession::publish`] -//! before reaching a sink. This crate does not verify signatures, load saved -//! sessions, or execute operations itself. +//! It uses the resulting key to select both the agent's saved state and its +//! [`IfcSession`]. Keep that session across turns: recreating it would forget +//! whether unlabeled input had reached the agent. +//! +//! Check reads before delivering data, calls before executing them, and +//! publications with [`IfcSession::publish`] before sending them to a sink. +//! This crate does not verify signatures, check live membership, load saved +//! sessions, isolate agent processes, or execute operations itself. #![forbid(unsafe_code)] diff --git a/crates/buzz-ifc/src/session.rs b/crates/buzz-ifc/src/session.rs index f9f1f579e3..bf9180173d 100644 --- a/crates/buzz-ifc/src/session.rs +++ b/crates/buzz-ifc/src/session.rs @@ -89,19 +89,25 @@ impl AuthorizedPublication { /// sink. The audience checks follow the flow ordering in /// [Appendix G of the design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-g-security-labels-as-a-lattice). /// -/// The broker supplies the labels and serialized outbound request: +/// The broker keeps this value for as long as it keeps the agent's history, +/// files, or other state. The same domain key selects both. This example uses a +/// broker-owned pool so a later turn cannot reset the session's restrictions: /// /// ``` +/// # use std::collections::HashMap; /// # use buzz_ifc::{AuthorizedPublication, ConfidentialityLabel, ExecutionDomain, -/// # IfcError, IfcSession, ResourceLabel}; +/// # DomainKey, IfcError, IfcSession, ResourceLabel}; /// # fn broker_sink(_: AuthorizedPublication) {} /// # fn run_turn( +/// # sessions: &mut HashMap, /// # domain: ExecutionDomain, /// # resource: &ResourceLabel, /// # destination: &ConfidentialityLabel, /// # request_bytes: Vec, /// # ) -> Result<(), IfcError> { -/// let session = IfcSession::enter(domain); +/// let session = sessions +/// .entry(domain.key()) +/// .or_insert_with(|| IfcSession::enter(domain)); /// session.call("buzz.read.current")?; /// session.read(resource)?; /// @@ -122,6 +128,9 @@ impl IfcSession { /// The domain's audience is observed immediately because retained agent /// state and broker-provided instructions may already influence the next /// output before the first explicit resource read. + /// + /// Do not replace an existing session while retaining the agent's state. + /// A fresh session would forget any earlier [`Self::mark_unknown_input`]. pub fn enter(domain: ExecutionDomain) -> Self { let mut flow = FlowState::default(); flow.observe(&domain.audience); @@ -138,6 +147,8 @@ impl IfcSession { /// The broker must not deliver a rejected resource. Every admitted resource /// is readable by the domain's entire audience, so reading it does not /// further restrict output: `enter` already applied that audience. + /// The broker must check current resource permissions separately; matching + /// stored membership epochs does not prove that membership is still current. pub fn read(&self, resource: &ResourceLabel) -> Result<(), IfcError> { if !resource.audience.can_flow_to(&self.domain.audience) { return Err(IfcError::ReadAudienceDenied); diff --git a/crates/buzz-ifc/src/session_tests.rs b/crates/buzz-ifc/src/session_tests.rs index 7ac7ecf318..6513c31456 100644 --- a/crates/buzz-ifc/src/session_tests.rs +++ b/crates/buzz-ifc/src/session_tests.rs @@ -304,6 +304,39 @@ fn unknown_input_permanently_blocks_publication() { ); } +/// Reusing agent history also reuses its IFC state. Two public channels share +/// a domain, so routing the next turn through another channel must not clear +/// the unknown-input flag. This exercises the pool lookup used in the example. +#[test] +fn unknown_input_survives_reusing_a_session_in_another_public_channel() { + let first_turn = public_domain(community(1), 10, "community:v1"); + let next_turn = public_domain(community(1), 20, "community:v1"); + let resource = ResourceLabel::from_domain(&next_turn); + let destination = next_turn.audience().clone(); + let mut pool = HashMap::new(); + + pool.entry(first_turn.key()) + .or_insert_with(|| IfcSession::enter(first_turn)) + .mark_unknown_input(); + + let session = pool + .entry(next_turn.key()) + .or_insert_with(|| IfcSession::enter(next_turn)); + session + .call(READ) + .expect("the read operation remains allowed"); + session.read(&resource).expect("public input is admissible"); + assert_eq!( + session + .publish(REPLY, &destination, b"output".to_vec()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::UnresolvedInput + )) + ); + assert_eq!(pool.len(), 1); +} + /// Retained restricted state from an older membership snapshot cannot enter a /// newly routed session for the same conversation. #[test] @@ -368,6 +401,37 @@ fn equal_audiences_do_not_merge_restricted_conversation_contexts() { assert_eq!(session.read(&resource), Err(IfcError::ReadContextDenied)); } +/// The owner may bring conversation data into owner-private work, but not the +/// reverse. Equal audiences must not let an ordinary conversation import the +/// owner's private history or memory. +#[test] +fn owner_private_state_cannot_enter_a_conversation_with_the_same_audience() { + let owner_private = owner_private_domain(community(1), 10, "membership:v1"); + let conversation = restricted_domain(community(1), 20, "membership:v1", &[1]); + assert_eq!(owner_private.audience(), conversation.audience()); + let resource = ResourceLabel::from_domain(&owner_private); + let session = IfcSession::enter(conversation); + + assert_eq!(session.read(&resource), Err(IfcError::ReadContextDenied)); +} + +/// Public means public within one community, not readable across all of them. +/// A cross-community resource must be rejected before any data is delivered. +#[test] +fn public_resource_cannot_cross_communities() { + let source = public_domain(community(1), 10, "community:v1"); + let destination = public_domain(community(2), 10, "community:v1"); + let resource = ResourceLabel::from_domain(&source); + let session = IfcSession::enter(destination); + let mut inbox = Vec::new(); + + assert_eq!( + deliver_to_agent(&session, &resource, "other community", &mut inbox), + Err(IfcError::ReadAudienceDenied) + ); + assert!(inbox.is_empty()); +} + /// Universes are isolated even when two communities happen to contain the /// same principals. A target in another community is never a valid IFC sink. #[test]