Clarify IFC session reuse and test retained-state boundaries

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-09-11 11:09:52 -07:00
parent 5107c0528f
commit fe75084a9e
3 changed files with 86 additions and 7 deletions

View File

@@ -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)]

View File

@@ -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<DomainKey, IfcSession>,
/// # domain: ExecutionDomain,
/// # resource: &ResourceLabel,
/// # destination: &ConfidentialityLabel,
/// # request_bytes: Vec<u8>,
/// # ) -> 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);

View File

@@ -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]