diff --git a/Cargo.lock b/Cargo.lock index d436016fd62..4e485cab795 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1126,6 +1126,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-ifc" +version = "0.1.0" +dependencies = [ + "hex", + "nostr 0.44.7", + "serde", + "sha2 0.11.0", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "buzz-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..6c901172ba3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/buzz-search", "crates/buzz-audit", "crates/buzz-acp", + "crates/buzz-ifc", "crates/buzz-agent", "crates/sprig", "crates/buzz-test-client", @@ -137,6 +138,7 @@ schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } +buzz-ifc = { path = "crates/buzz-ifc" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-deletion = { path = "crates/buzz-deletion" } diff --git a/crates/buzz-ifc/Cargo.toml b/crates/buzz-ifc/Cargo.toml new file mode 100644 index 00000000000..3d5f998943a --- /dev/null +++ b/crates/buzz-ifc/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "buzz-ifc" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Audience-scoped information-flow policy for Buzz agents" + +[dependencies] +hex = { workspace = true } +nostr = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-ifc/src/declassification.rs b/crates/buzz-ifc/src/declassification.rs new file mode 100644 index 00000000000..0d04889d02f --- /dev/null +++ b/crates/buzz-ifc/src/declassification.rs @@ -0,0 +1,136 @@ +use std::marker::PhantomData; + +use crate::domain::DomainContext; +use crate::label::{ConfidentialityLabel, Principal}; + +/// Typestate marker for a grant whose owner signature has not been checked. +pub struct PendingGrant; + +/// Typestate marker for a grant authenticated by an external verifier. +pub struct VerifiedGrant; + +/// Paper: "Declassification." A content- and destination-specific grant. +/// +/// A verified value is consumed by its first matching publication decision. +/// The broker remains responsible for durable replay and expiry enforcement +/// before verification. +pub struct DeclassificationGrant { + approver: Principal, + source_domain_id: String, + destination: ConfidentialityLabel, + destination_context: DomainContext, + content_digest: [u8; 32], + consumed: bool, + _state: PhantomData, +} + +impl DeclassificationGrant { + /// Return the principal that approved this release. + pub fn approver(&self) -> &Principal { + &self.approver + } + + /// Return the exact execution domain from which the content came. + pub fn source_domain_id(&self) -> &str { + &self.source_domain_id + } + + /// Return the audience approved to receive the content. + pub fn destination(&self) -> &ConfidentialityLabel { + &self.destination + } + + /// Return the exact destination context approved for the release. + pub fn destination_context(&self) -> &DomainContext { + &self.destination_context + } + + /// Return the digest of the exact content approved for release. + pub fn content_digest(&self) -> &[u8; 32] { + &self.content_digest + } +} + +/// Verifies the owner signature over a pending grant's canonical payload. +pub trait GrantSignatureVerifier { + /// Return true only when the grant bears an authentic owner signature and + /// its externally stored expiry or replay policy still permits use. + fn verifies(&self, grant: &DeclassificationGrant) -> bool; +} + +impl DeclassificationGrant { + /// Construct an unverified grant from signed-event fields. + pub fn pending( + approver: Principal, + source_domain_id: String, + destination: ConfidentialityLabel, + destination_context: DomainContext, + content_digest: [u8; 32], + ) -> Self { + Self { + approver, + source_domain_id, + destination, + destination_context, + content_digest, + consumed: false, + _state: PhantomData, + } + } + + /// Authenticate the owner and move the grant into the verified typestate. + pub fn verify( + self, + expected_owner: &Principal, + verifier: &V, + ) -> Result, GrantError> { + if &self.approver != expected_owner { + return Err(GrantError::WrongApprover); + } + if !verifier.verifies(&self) { + return Err(GrantError::InvalidSignature); + } + Ok(DeclassificationGrant { + approver: self.approver, + source_domain_id: self.source_domain_id, + destination: self.destination, + destination_context: self.destination_context, + content_digest: self.content_digest, + consumed: false, + _state: PhantomData, + }) + } +} + +impl DeclassificationGrant { + pub(crate) fn matches( + &mut self, + source_domain_id: &str, + destination: &ConfidentialityLabel, + destination_context: &DomainContext, + content_digest: &[u8; 32], + ) -> bool { + if self.consumed { + return false; + } + let matches = self.source_domain_id == source_domain_id + && &self.destination == destination + && &self.destination_context == destination_context + && &self.content_digest == content_digest; + if matches { + self.consumed = true; + } + matches + } +} + +/// A declassification grant failed owner authentication. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum GrantError { + /// The signer is not the expected bot owner. + #[error("declassification grant was signed by the wrong principal")] + WrongApprover, + /// The supplied signature did not authenticate the grant. + #[error("declassification grant signature is invalid")] + InvalidSignature, +} diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs new file mode 100644 index 00000000000..ff764d58e39 --- /dev/null +++ b/crates/buzz-ifc/src/domain.rs @@ -0,0 +1,592 @@ +use std::collections::BTreeSet; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::hash::{hash_field, short_fingerprint}; +use crate::label::{ConfidentialityLabel, LabelError, Principal, ReaderSet, RealmId}; + +/// Which retained context a worker belongs to. +/// +/// Paper: "Execution domains." Context remains separate from audience: two +/// conversations may have identical participants without implicitly sharing +/// memory. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum DomainContext { + /// Shared state for public conversations in one realm. + RealmPublic(RealmId), + /// State retained for one specific restricted conversation. + Conversation { + /// The Buzz community containing the conversation. + realm: RealmId, + /// The channel, DM, or group-DM identifier. + channel_id: Uuid, + }, + /// State visible only to the bot owner. + OwnerPrivate { + /// The Buzz community containing the owner relationship. + realm: RealmId, + /// The bot owner. + owner: Principal, + }, +} + +/// Runtime placement required by an execution domain. +/// +/// The IFC rules do not implement an OS sandbox. They tell the harness whether +/// a worker may use the shared public runtime or must be placed in a compartment +/// dedicated to one complete execution domain. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompartmentProfile { + /// Realm-public conversations may share a worker, public memory, and public + /// tools. The worker must still be unable to reach broker secrets or private + /// compartments. + SharedPublic, + /// A restricted conversation or owner-private task requires a worker whose + /// writable state and output paths are confined to the exact domain. + DomainConfined, +} + +impl CompartmentProfile { + /// Return the stable wire and log representation. + pub fn as_str(self) -> &'static str { + match self { + Self::SharedPublic => "shared_public", + Self::DomainConfined => "domain_confined", + } + } +} + +impl DomainContext { + /// Return the realm containing this context. + pub fn realm(&self) -> &RealmId { + match self { + Self::RealmPublic(realm) + | Self::Conversation { realm, .. } + | Self::OwnerPrivate { realm, .. } => realm, + } + } + + /// Return a stable context category for logs and protocol responses. + pub fn kind(&self) -> &'static str { + match self { + Self::RealmPublic(_) => "public", + Self::Conversation { .. } => "conversation", + Self::OwnerPrivate { .. } => "owner_private", + } + } + + /// Whether this is the private aggregation context for `owner`. + pub fn is_owner_private_for(&self, owner: &Principal) -> bool { + matches!(self, Self::OwnerPrivate { owner: candidate, .. } if candidate == owner) + } + + fn resource_context(&self) -> ResourceContext { + match self { + Self::RealmPublic(realm) => ResourceContext::RealmPublic(realm.clone()), + Self::Conversation { realm, channel_id } => ResourceContext::Conversation { + realm: realm.clone(), + channel_id: *channel_id, + }, + Self::OwnerPrivate { realm, owner } => ResourceContext::OwnerPrivate { + realm: realm.clone(), + owner: owner.clone(), + }, + } + } + + pub(crate) fn permits(&self, resource: &ResourceContext) -> bool { + match resource { + ResourceContext::TrustedConfiguration => true, + ResourceContext::RealmPublic(resource_realm) => self.realm() == resource_realm, + ResourceContext::Conversation { + realm: resource_realm, + channel_id: resource_channel, + } => match self { + Self::Conversation { realm, channel_id } => { + realm == resource_realm && channel_id == resource_channel + } + Self::OwnerPrivate { realm, .. } => realm == resource_realm, + Self::RealmPublic(_) => false, + }, + ResourceContext::OwnerPrivate { + realm: resource_realm, + owner: resource_owner, + } => matches!( + self, + Self::OwnerPrivate { realm, owner } + if realm == resource_realm && owner == resource_owner + ), + } + } + + fn stable_hash(&self, hasher: &mut Sha256) { + match self { + Self::RealmPublic(realm) => { + hasher.update(b"realm-public"); + realm.stable_hash(hasher); + } + Self::Conversation { realm, channel_id } => { + hasher.update(b"conversation"); + realm.stable_hash(hasher); + hasher.update(channel_id.as_bytes()); + } + Self::OwnerPrivate { realm, owner } => { + hasher.update(b"owner-private"); + realm.stable_hash(hasher); + hash_field(hasher, owner.0.as_bytes()); + } + } + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum ResourceContext { + TrustedConfiguration, + RealmPublic(RealmId), + Conversation { realm: RealmId, channel_id: Uuid }, + OwnerPrivate { realm: RealmId, owner: Principal }, +} + +/// An operation that a worker may request. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct Capability(String); + +/// The complete set of operations admitted for one execution domain. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CapabilitySet(BTreeSet); + +impl CapabilitySet { + /// Build a set from stable operation names. + pub fn from_names(names: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self( + names + .into_iter() + .map(|name| Capability(name.into())) + .collect(), + ) + } + + /// Paper: "Broker behavior — Enforcement rules." Compute + /// `C(bot) ∩ C(requester) ∩ C(domain)`. + pub fn effective(bot: &Self, requester: &Self, domain: &Self) -> Self { + let bot_and_requester: BTreeSet<_> = bot.0.intersection(&requester.0).cloned().collect(); + Self(bot_and_requester.intersection(&domain.0).cloned().collect()) + } + + /// Whether this set admits an operation. + pub fn contains(&self, operation: &str) -> bool { + self.0.contains(&Capability(operation.to_string())) + } + + fn stable_hash(&self, hasher: &mut Sha256) { + for capability in &self.0 { + hash_field(hasher, capability.0.as_bytes()); + } + } +} + +/// Capability ceilings used while deriving an invocation's effective set. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityPolicy { + bot: CapabilitySet, + conversation: CapabilitySet, +} + +impl CapabilityPolicy { + /// Construct policy from the bot's full ceiling and the ceiling permitted + /// in shared Buzz conversations. + pub fn new(bot: CapabilitySet, conversation: CapabilitySet) -> Self { + Self { bot, conversation } + } +} + +/// The membership or policy version under which state was created. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MembershipEpoch(String); + +impl MembershipEpoch { + /// Construct an epoch from a stable, verifier-controlled identifier. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Return a short identifier suitable for logs. + pub fn fingerprint(&self) -> String { + short_fingerprint(&self.0) + } +} + +/// Buzz conversation classification after signed metadata verification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConversationKind { + /// Realm-wide public channel. All public channels intentionally share one + /// execution domain. + Public, + /// Invite-only channel or group DM with conversation-specific state. + Restricted, + /// A DM. A two-party owner/bot DM becomes owner-private; group DMs remain + /// conversation-specific. + DirectMessage, +} + +/// Verified Buzz facts from which the shared policy derives an execution +/// domain. +/// +/// A trusted adapter constructs this only after checking trigger signatures, +/// channel binding, and the relay signature on metadata and membership. +pub struct DomainFacts { + /// Community realm selected by the trusted Buzz connection. + pub realm: RealmId, + /// Channel, DM, or group-DM identifier that triggered the invocation. + pub channel_id: Uuid, + /// Verified conversation classification. + pub kind: ConversationKind, + /// Relay-controlled membership or community policy version. + pub epoch: MembershipEpoch, + /// Complete verified roster. Public derivation does not consume this set. + pub members: BTreeSet, + /// Managed Buzz identity whose work the execution domain contains. + pub executing_agent: Principal, + /// Authors whose events are included in this invocation. + pub requesters: BTreeSet, + /// Optional relay principal allowed to author trusted workflow events. + pub system_principal: Option, + /// Optional human owner of the executing agent. + pub owner: Option, +} + +/// Domain derivation failed despite the adapter's claim that its facts were +/// already verified. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DerivationError { + /// Every invocation must contain at least one authenticated requester. + #[error("invocation has no authenticated requester")] + EmptyRequesters, + /// Restricted conversations must include the executing agent in their + /// verified roster. + #[error("executing agent is absent from channel membership")] + AgentNotMember, + /// A non-system requester is absent from restricted membership. + #[error("requester is absent from channel membership")] + RequesterNotMember, + /// Removing the executing processor left no authorized recipient. + #[error("restricted conversation has no recipient audience")] + EmptyRestrictedAudience, + /// The derived audience and context violated a domain invariant. + #[error("derived execution domain is inconsistent")] + InvalidDomain, +} + +/// Paper: "Execution domains." Derive +/// `D = (Agent, Audience, Context, Epoch, Capabilities)` from verified Buzz +/// facts. +/// This is the mapping shared by local ACP and remote agent harnesses. +pub fn derive_execution_domain( + facts: DomainFacts, + policy: &CapabilityPolicy, +) -> Result { + if facts.requesters.is_empty() { + return Err(DerivationError::EmptyRequesters); + } + + if facts.kind == ConversationKind::Public { + let context = DomainContext::RealmPublic(facts.realm.clone()); + let capabilities = effective_capabilities(&context, &facts, policy); + return Ok(ExecutionDomain::public( + facts.executing_agent, + facts.realm, + facts.epoch, + capabilities, + )); + } + + if !facts.members.contains(&facts.executing_agent) { + return Err(DerivationError::AgentNotMember); + } + if facts.requesters.iter().any(|requester| { + facts.system_principal.as_ref() != Some(requester) && !facts.members.contains(requester) + }) { + return Err(DerivationError::RequesterNotMember); + } + + let mut readers = facts.members.clone(); + readers.remove(&facts.executing_agent); + if readers.is_empty() { + return Err(DerivationError::EmptyRestrictedAudience); + } + + let context = match (&facts.owner, facts.kind) { + (Some(owner), ConversationKind::DirectMessage) + if readers.len() == 1 && readers.contains(owner) => + { + DomainContext::OwnerPrivate { + realm: facts.realm.clone(), + owner: owner.clone(), + } + } + _ => DomainContext::Conversation { + realm: facts.realm.clone(), + channel_id: facts.channel_id, + }, + }; + let capabilities = effective_capabilities(&context, &facts, policy); + let audience = ConfidentialityLabel::restricted(facts.realm, readers) + .map_err(|_| DerivationError::EmptyRestrictedAudience)?; + ExecutionDomain::new( + facts.executing_agent, + audience, + context, + facts.epoch, + capabilities, + ) + .map_err(|_| DerivationError::InvalidDomain) +} + +fn effective_capabilities( + context: &DomainContext, + facts: &DomainFacts, + policy: &CapabilityPolicy, +) -> CapabilitySet { + let requester_is_owner = facts + .owner + .as_ref() + .is_some_and(|owner| facts.requesters.iter().all(|requester| requester == owner)); + let requester = if requester_is_owner { + &policy.bot + } else { + &policy.conversation + }; + let domain = if matches!(context, DomainContext::OwnerPrivate { .. }) { + &policy.bot + } else { + &policy.conversation + }; + CapabilitySet::effective(&policy.bot, requester, domain) +} + +/// Opaque routing key for one complete execution domain. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct DomainKey(String); + +impl DomainKey { + /// Return a short identifier suitable for logs. + pub fn fingerprint(&self) -> String { + short_fingerprint(&self.0) + } + + /// Return the full stable identifier used as a worker-pool routing key. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// `D = (Agent, Audience, Context, Epoch, Capabilities)`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionDomain { + agent: Principal, + pub(crate) audience: ConfidentialityLabel, + pub(crate) context: DomainContext, + epoch: MembershipEpoch, + pub(crate) capabilities: CapabilitySet, +} + +impl ExecutionDomain { + /// Construct a domain after the trusted adapter has resolved its inputs. + pub fn new( + agent: Principal, + audience: ConfidentialityLabel, + context: DomainContext, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Result { + if audience.realm() != context.realm() { + return Err(DomainError::ContextRealmMismatch); + } + match (&audience.readers, &context) { + (ReaderSet::Everyone, DomainContext::RealmPublic(_)) => {} + (ReaderSet::Only(readers), DomainContext::Conversation { .. }) + if !readers.is_empty() => {} + (ReaderSet::Only(readers), DomainContext::OwnerPrivate { owner, .. }) + if readers.len() == 1 && readers.contains(owner) => {} + _ => return Err(DomainError::AudienceContextMismatch), + } + Ok(Self { + agent, + audience, + context, + epoch, + capabilities, + }) + } + + /// Construct the realm-wide public domain. + pub fn public( + agent: Principal, + realm: RealmId, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Self { + Self { + agent, + audience: ConfidentialityLabel::public(realm.clone()), + context: DomainContext::RealmPublic(realm), + epoch, + capabilities, + } + } + + /// Construct the exact owner-private domain. + pub fn owner_private( + agent: Principal, + realm: RealmId, + owner: Principal, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Self { + let readers = BTreeSet::from([owner.clone()]); + Self { + agent, + audience: ConfidentialityLabel { + realm: realm.clone(), + readers: ReaderSet::Only(readers), + }, + context: DomainContext::OwnerPrivate { realm, owner }, + epoch, + capabilities, + } + } + + /// Return the managed Buzz identity whose work this domain contains. + pub fn agent(&self) -> &Principal { + &self.agent + } + + /// Return the authorized audience. + pub fn audience(&self) -> &ConfidentialityLabel { + &self.audience + } + + /// Return the retained-state context. + pub fn context(&self) -> &DomainContext { + &self.context + } + + /// Return the runtime placement required to preserve this domain. + pub fn compartment_profile(&self) -> CompartmentProfile { + if self.audience.is_public() { + CompartmentProfile::SharedPublic + } else { + CompartmentProfile::DomainConfined + } + } + + /// Return the effective capability set. + pub fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Return a short fingerprint of the membership or policy epoch. + pub fn epoch_fingerprint(&self) -> String { + self.epoch.fingerprint() + } + + /// Return the canonical identifier for the complete domain tuple. + pub fn id(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-ifc-domain-v1"); + hash_field(&mut hasher, self.agent.0.as_bytes()); + self.audience.realm.stable_hash(&mut hasher); + self.audience.readers.stable_hash(&mut hasher); + self.context.stable_hash(&mut hasher); + hash_field(&mut hasher, self.epoch.0.as_bytes()); + self.capabilities.stable_hash(&mut hasher); + hex::encode(hasher.finalize()) + } + + /// Return the opaque worker-pool routing key. + pub fn key(&self) -> DomainKey { + DomainKey(self.id()) + } + + /// Label information whose provenance is this domain itself. + pub fn resource_label(&self) -> ResourceLabel { + ResourceLabel { + confidentiality: self.audience.clone(), + context: self.context.resource_context(), + } + } +} + +/// An execution domain contains inconsistent realms. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DomainError { + /// The audience and retained context belong to different Buzz realms. + #[error("execution-domain audience and context belong to different realms")] + ContextRealmMismatch, + /// Public, conversation, and owner-private contexts require their + /// corresponding audience shape. + #[error("execution-domain audience does not match its context")] + AudienceContextMismatch, +} + +/// The confidentiality and context assigned to one input resource. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceLabel { + pub(crate) confidentiality: ConfidentialityLabel, + pub(crate) context: ResourceContext, +} + +impl ResourceLabel { + /// Label immutable configuration that is public in the supplied realm. + pub fn trusted_configuration(realm: RealmId) -> Self { + Self { + confidentiality: ConfidentialityLabel::public(realm), + context: ResourceContext::TrustedConfiguration, + } + } + + /// Label information already scoped to an execution domain. + pub fn domain(domain: &ExecutionDomain) -> Self { + domain.resource_label() + } + + /// Label information public to every member of one realm. + pub fn realm_public(realm: RealmId) -> Self { + Self { + confidentiality: ConfidentialityLabel::public(realm.clone()), + context: ResourceContext::RealmPublic(realm), + } + } + + /// Label information belonging to one restricted conversation. + pub fn conversation( + realm: RealmId, + channel_id: Uuid, + readers: BTreeSet, + ) -> Result { + Ok(Self { + confidentiality: ConfidentialityLabel::restricted(realm.clone(), readers)?, + context: ResourceContext::Conversation { realm, channel_id }, + }) + } + + /// Label owner-private information such as personal memory. + pub fn owner_private(realm: RealmId, owner: Principal) -> Self { + let readers = BTreeSet::from([owner.clone()]); + Self { + confidentiality: ConfidentialityLabel { + realm: realm.clone(), + readers: ReaderSet::Only(readers), + }, + context: ResourceContext::OwnerPrivate { realm, owner }, + } + } +} diff --git a/crates/buzz-ifc/src/hash.rs b/crates/buzz-ifc/src/hash.rs new file mode 100644 index 00000000000..f33096d4f7b --- /dev/null +++ b/crates/buzz-ifc/src/hash.rs @@ -0,0 +1,12 @@ +use sha2::{Digest, Sha256}; + +pub(crate) fn hash_field(hasher: &mut Sha256, value: &[u8]) { + let length = u64::try_from(value.len()).unwrap_or(u64::MAX); + hasher.update(length.to_be_bytes()); + hasher.update(value); +} + +pub(crate) fn short_fingerprint(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..6]) +} diff --git a/crates/buzz-ifc/src/label.rs b/crates/buzz-ifc/src/label.rs new file mode 100644 index 00000000000..7fdeb9ea2ff --- /dev/null +++ b/crates/buzz-ifc/src/label.rs @@ -0,0 +1,202 @@ +use std::collections::BTreeSet; + +use nostr::PublicKey; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::hash::hash_field; + +/// A Buzz principal represented by a validated, normalized Nostr public key. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Principal(pub(crate) String); + +impl Principal { + /// Parse and normalize a hexadecimal Nostr public key. + pub fn from_hex(value: &str) -> Result { + let key = PublicKey::from_hex(value).map_err(|_| PrincipalError::InvalidPublicKey)?; + Self::from_public_key(&key) + } + + /// Validate and convert a Nostr public key. + /// + /// PublicKey can hold any 32-byte value, including values that are not + /// valid x-only secp256k1 points. IFC identities must reject those values + /// before they enter reader sets or domain keys. + pub fn from_public_key(value: &PublicKey) -> Result { + value + .xonly() + .map_err(|_| PrincipalError::InvalidPublicKey)?; + Ok(Self(value.to_hex().to_ascii_lowercase())) + } + + /// Return the normalized hexadecimal public key. + pub fn as_hex(&self) -> &str { + &self.0 + } +} + +/// A principal could not be constructed from the supplied key. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PrincipalError { + /// The value is not a valid Nostr public key. + #[error("invalid Nostr public key")] + InvalidPublicKey, +} + +/// A confidentiality universe derived from one canonical Buzz relay URL. +/// +/// Public data in one community is not public in another, so labels from +/// different realms never flow to one another. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct RealmId(pub(crate) [u8; 32]); + +impl RealmId { + /// Derive a realm identifier from the relay URL selected by Buzz. + pub fn from_relay_url(relay_url: &str) -> Self { + Self(Sha256::digest(relay_url.as_bytes()).into()) + } + + /// Return a short identifier suitable for structured logs. + pub fn fingerprint(&self) -> String { + hex::encode(&self.0[..6]) + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + hasher.update(self.0); + } +} + +/// The authorized readers of a value. +/// +/// Paper: "Appendix: Security labels as a lattice — Labels and ordering." +/// `Everyone` is the public lattice element. An explicit set becomes more +/// restrictive as principals are removed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ReaderSet { + Everyone, + Only(BTreeSet), +} + +impl ReaderSet { + /// The IFC ordering is reverse set inclusion: every reader at the + /// destination must already be authorized to read the source. + fn can_flow_to(&self, destination: &Self) -> bool { + match (self, destination) { + (Self::Everyone, _) => true, + (Self::Only(_), Self::Everyone) => false, + (Self::Only(source), Self::Only(destination)) => destination.is_subset(source), + } + } + + /// Paper: "Combining information." Inputs are joined by intersecting their + /// authorized reader sets. + pub(crate) fn join(&self, other: &Self) -> Self { + match (self, other) { + (Self::Everyone, value) | (value, Self::Everyone) => value.clone(), + (Self::Only(left), Self::Only(right)) => { + Self::Only(left.intersection(right).cloned().collect()) + } + } + } + + #[cfg(test)] + pub(crate) fn meet(&self, other: &Self) -> Self { + match (self, other) { + (Self::Everyone, _) | (_, Self::Everyone) => Self::Everyone, + (Self::Only(left), Self::Only(right)) => { + Self::Only(left.union(right).cloned().collect()) + } + } + } + + fn explicit_count(&self) -> Option { + match self { + Self::Everyone => None, + Self::Only(readers) => Some(readers.len()), + } + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + match self { + Self::Everyone => hasher.update(b"everyone"), + Self::Only(readers) => { + hasher.update(b"only"); + for reader in readers { + hash_field(hasher, reader.0.as_bytes()); + } + } + } + } +} + +/// A reader-set label within one Buzz community. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfidentialityLabel { + pub(crate) realm: RealmId, + pub(crate) readers: ReaderSet, +} + +impl ConfidentialityLabel { + /// Label data that every member of the realm may read. + pub fn public(realm: RealmId) -> Self { + Self { + realm, + readers: ReaderSet::Everyone, + } + } + + /// Label data with an explicit non-empty authorized reader set. + pub fn restricted(realm: RealmId, readers: BTreeSet) -> Result { + if readers.is_empty() { + return Err(LabelError::EmptyReaderSet); + } + Ok(Self { + realm, + readers: ReaderSet::Only(readers), + }) + } + + /// Return the realm in which this label is meaningful. + pub fn realm(&self) -> &RealmId { + &self.realm + } + + /// Whether the label permits every member of the realm to read the value. + pub fn is_public(&self) -> bool { + matches!(self.readers, ReaderSet::Everyone) + } + + /// Return the explicit number of readers, or `None` for public data. + pub fn reader_count(&self) -> Option { + self.readers.explicit_count() + } + + /// Whether information with this label may flow to `destination`. + pub fn can_flow_to(&self, destination: &Self) -> bool { + self.realm == destination.realm && self.readers.can_flow_to(&destination.readers) + } + + /// Combine the influence of two inputs. + pub fn join(&self, other: &Self) -> Result { + if self.realm != other.realm { + return Err(LabelError::CrossRealm); + } + Ok(Self { + realm: self.realm.clone(), + readers: self.readers.join(&other.readers), + }) + } +} + +/// A confidentiality label violates the reader-set lattice invariants. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum LabelError { + /// Restricted information must name at least one authorized reader. + #[error("restricted label has no authorized readers")] + EmptyReaderSet, + /// Labels from different Buzz communities cannot be combined. + #[error("labels belong to different Buzz realms")] + CrossRealm, +} diff --git a/crates/buzz-ifc/src/lib.rs b/crates/buzz-ifc/src/lib.rs new file mode 100644 index 00000000000..03ef86d82c9 --- /dev/null +++ b/crates/buzz-ifc/src/lib.rs @@ -0,0 +1,491 @@ +//! Deterministic information-flow policy for Buzz agent execution. +//! +//! This crate contains no relay, ACP, process, or storage code. A trusted Buzz +//! adapter verifies events and membership, supplies [`DomainFacts`], and uses +//! these rules to derive an [`ExecutionDomain`] and decide whether a worker may +//! be reused, read a resource, call an operation, or publish a result. Keeping +//! the policy pure lets local ACP and remote harnesses apply the same rules +//! without sharing an agent implementation. +//! +//! Comments prefixed with `Paper:` identify the matching section of +//! "Practical information-flow for Buzz agents." + +mod declassification; +mod domain; +mod hash; +mod label; +mod policy; + +pub use declassification::{ + DeclassificationGrant, GrantError, GrantSignatureVerifier, PendingGrant, VerifiedGrant, +}; +pub use domain::{ + derive_execution_domain, CapabilityPolicy, CapabilitySet, CompartmentProfile, ConversationKind, + DerivationError, DomainContext, DomainError, DomainFacts, DomainKey, ExecutionDomain, + MembershipEpoch, ResourceLabel, +}; +pub use label::{ConfidentialityLabel, LabelError, Principal, PrincipalError, RealmId}; +pub use policy::{ProcessState, RuleDecision, RuleEvaluator}; + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use nostr::Keys; + use uuid::Uuid; + + use super::*; + use crate::label::ReaderSet; + + fn principal(value: u8) -> Principal { + let secret_key = format!("{value:064x}"); + let keys = Keys::parse(&secret_key).expect("test secret key"); + Principal::from_public_key(&keys.public_key()).expect("test principal") + } + + fn readers(values: &[u8]) -> BTreeSet { + values.iter().copied().map(principal).collect() + } + + fn realm() -> RealmId { + RealmId::from_relay_url("wss://buzz.example") + } + + fn label(values: &[u8]) -> ConfidentialityLabel { + ConfidentialityLabel::restricted(realm(), readers(values)).expect("non-empty readers") + } + + #[test] + fn principals_must_be_valid_x_only_secp256k1_points() { + for invalid in ["00".repeat(32), "ff".repeat(32), format!("{:064x}", 5)] { + assert_eq!( + Principal::from_hex(&invalid), + Err(PrincipalError::InvalidPublicKey) + ); + } + } + + fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain { + ExecutionDomain::new( + principal(9), + label(values), + DomainContext::Conversation { + realm: realm(), + channel_id, + }, + MembershipEpoch::new(epoch), + CapabilitySet::from_names(["buzz.read.current"]), + ) + .expect("same realm") + } + + #[test] + fn reader_sets_obey_lattice_laws() { + let values = [ + ReaderSet::Everyone, + ReaderSet::Only(readers(&[1])), + ReaderSet::Only(readers(&[2])), + ReaderSet::Only(readers(&[1, 2])), + ]; + + for a in &values { + assert_eq!(a.join(a), *a); + assert_eq!(a.meet(a), *a); + for b in &values { + assert_eq!(a.join(b), b.join(a)); + assert_eq!(a.meet(b), b.meet(a)); + assert_eq!(a.join(&a.meet(b)), *a); + assert_eq!(a.meet(&a.join(b)), *a); + for c in &values { + assert_eq!(a.join(&b.join(c)), a.join(b).join(c)); + assert_eq!(a.meet(&b.meet(c)), a.meet(b).meet(c)); + } + } + } + } + + #[test] + fn combining_inputs_intersects_authorized_readers() { + let left = label(&[1, 2]); + let right = label(&[1, 3]); + let combined = left.join(&right).expect("same realm"); + + assert!(combined.can_flow_to(&label(&[1]))); + assert!(!combined.can_flow_to(&label(&[1, 2]))); + } + + #[test] + fn labels_never_flow_across_realms() { + let first = ConfidentialityLabel::public(RealmId::from_relay_url("wss://one")); + let second = ConfidentialityLabel::public(RealmId::from_relay_url("wss://two")); + + assert!(!first.can_flow_to(&second)); + assert_eq!(first.join(&second), Err(LabelError::CrossRealm)); + } + + #[test] + fn read_requires_both_audience_and_context() { + let channel = Uuid::from_u128(1); + let domain = conversation(&[1, 2], channel, "v1"); + let wrong_audience = ResourceLabel::conversation(realm(), channel, readers(&[1])) + .expect("non-empty readers"); + let wrong_context = + ResourceLabel::conversation(realm(), Uuid::from_u128(2), readers(&[1, 2])) + .expect("non-empty readers"); + + assert!(!RuleEvaluator::read(&domain, &wrong_audience).allowed()); + assert!(!RuleEvaluator::read(&domain, &wrong_context).allowed()); + assert!(RuleEvaluator::read(&domain, &domain.resource_label()).allowed()); + } + + #[test] + fn owner_private_context_aggregates_only_owner_readable_conversations() { + let owner = principal(1); + let domain = ExecutionDomain::owner_private( + principal(9), + realm(), + owner, + MembershipEpoch::new("owner-v1"), + CapabilitySet::default(), + ); + let readable = ResourceLabel::conversation(realm(), Uuid::from_u128(1), readers(&[1, 2])) + .expect("non-empty readers"); + let unreadable = ResourceLabel::conversation(realm(), Uuid::from_u128(2), readers(&[2, 3])) + .expect("non-empty readers"); + + assert!(RuleEvaluator::read(&domain, &readable).allowed()); + assert!(!RuleEvaluator::read(&domain, &unreadable).allowed()); + } + + #[test] + fn effective_capabilities_are_the_three_way_intersection() { + let bot = CapabilitySet::from_names(["buzz.read.current", "email.read", "drive.read"]); + let requester = CapabilitySet::from_names(["buzz.read.current", "email.read"]); + let domain = CapabilitySet::from_names(["buzz.read.current", "drive.read"]); + + assert_eq!( + CapabilitySet::effective(&bot, &requester, &domain), + CapabilitySet::from_names(["buzz.read.current"]) + ); + } + + #[test] + fn domain_derivation_grants_personal_tools_only_in_owner_private_work() { + let owner = principal(1); + let agent = principal(9); + let policy = CapabilityPolicy::new( + CapabilitySet::from_names(["buzz.read.current", "email.read"]), + CapabilitySet::from_names(["buzz.read.current"]), + ); + let owner_dm = derive_execution_domain( + DomainFacts { + realm: realm(), + channel_id: Uuid::from_u128(1), + kind: ConversationKind::DirectMessage, + epoch: MembershipEpoch::new("membership:v1"), + members: BTreeSet::from([agent.clone(), owner.clone()]), + executing_agent: agent.clone(), + requesters: BTreeSet::from([owner.clone()]), + system_principal: None, + owner: Some(owner.clone()), + }, + &policy, + ) + .expect("owner DM domain"); + assert!(owner_dm.context().is_owner_private_for(&owner)); + assert!(owner_dm.capabilities().contains("email.read")); + + let public = derive_execution_domain( + DomainFacts { + realm: realm(), + channel_id: Uuid::from_u128(2), + kind: ConversationKind::Public, + epoch: MembershipEpoch::new("community:v1"), + members: BTreeSet::new(), + executing_agent: agent, + requesters: BTreeSet::from([owner.clone()]), + system_principal: None, + owner: Some(owner), + }, + &policy, + ) + .expect("public domain"); + assert!(!public.capabilities().contains("email.read")); + } + + #[test] + fn restricted_domain_derivation_checks_agent_and_requester_membership() { + let owner = principal(1); + let agent = principal(9); + let outsider = principal(8); + let policy = CapabilityPolicy::new( + CapabilitySet::from_names(["buzz.read.current"]), + CapabilitySet::from_names(["buzz.read.current"]), + ); + let facts = |members, requesters| DomainFacts { + realm: realm(), + channel_id: Uuid::from_u128(1), + kind: ConversationKind::Restricted, + epoch: MembershipEpoch::new("membership:v1"), + members, + executing_agent: agent.clone(), + requesters, + system_principal: None, + owner: Some(owner.clone()), + }; + + assert_eq!( + derive_execution_domain( + facts( + BTreeSet::from([owner.clone()]), + BTreeSet::from([owner.clone()]), + ), + &policy, + ), + Err(DerivationError::AgentNotMember) + ); + assert_eq!( + derive_execution_domain( + facts( + BTreeSet::from([owner.clone(), agent.clone()]), + BTreeSet::from([outsider]), + ), + &policy, + ), + Err(DerivationError::RequesterNotMember) + ); + } + + #[test] + fn process_reuse_requires_the_complete_domain() { + let first = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let same = first.clone(); + let changed_epoch = conversation(&[1, 2], Uuid::from_u128(1), "v2"); + let mut state = ProcessState::default(); + + assert!(state.enter(&first).allowed()); + assert!(state.enter(&same).allowed()); + assert!(!state.enter(&changed_epoch).allowed()); + assert!(!state.enter(&first).allowed()); + state.observe(&first.resource_label()); + assert!(!state + .publish(&first, first.audience(), first.context(), &[1; 32], None) + .allowed()); + } + + #[test] + fn domain_id_has_a_canonical_golden_value() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1"); + assert_eq!( + domain.id(), + "2ada28a5b33888f827a5845fd6e84b37e954a31dd4d0652d2468015d68e9080a" + ); + } + + #[test] + fn domain_ids_bind_the_managed_agent_identity() { + let first = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1"); + let second = ExecutionDomain::new( + principal(8), + first.audience().clone(), + first.context().clone(), + MembershipEpoch::new("membership:event-1"), + first.capabilities().clone(), + ) + .expect("same realm"); + + assert_ne!(first.id(), second.id()); + } + + #[test] + fn domain_shape_rejects_a_public_audience_for_a_private_context() { + let result = ExecutionDomain::new( + principal(9), + ConfidentialityLabel::public(realm()), + DomainContext::Conversation { + realm: realm(), + channel_id: Uuid::from_u128(1), + }, + MembershipEpoch::new("v1"), + CapabilitySet::default(), + ); + + assert_eq!(result, Err(DomainError::AudienceContextMismatch)); + } + + #[test] + fn public_domain_ids_do_not_depend_on_the_triggering_channel() { + let first = ExecutionDomain::public( + principal(9), + realm(), + MembershipEpoch::new("community"), + CapabilitySet::from_names(["buzz.read.current"]), + ); + let second = ExecutionDomain::public( + principal(9), + realm(), + MembershipEpoch::new("community"), + CapabilitySet::from_names(["buzz.read.current"]), + ); + + assert_eq!(first.id(), second.id()); + } + + #[test] + fn compartment_profile_is_asymmetric() { + let public = ExecutionDomain::public( + principal(9), + realm(), + MembershipEpoch::new("community"), + CapabilitySet::default(), + ); + let restricted = conversation(&[1, 2], Uuid::from_u128(1), "membership:v1"); + let owner_private = ExecutionDomain::owner_private( + principal(9), + realm(), + principal(1), + MembershipEpoch::new("owner:v1"), + CapabilitySet::default(), + ); + + assert_eq!( + public.compartment_profile(), + CompartmentProfile::SharedPublic + ); + assert_eq!( + restricted.compartment_profile(), + CompartmentProfile::DomainConfined + ); + assert_eq!( + owner_private.compartment_profile(), + CompartmentProfile::DomainConfined + ); + } + + #[test] + fn denied_input_still_taints_an_audit_only_process() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let private = ResourceLabel::owner_private(realm(), principal(1)); + assert!(!RuleEvaluator::read(&domain, &private).allowed()); + + let mut state = ProcessState::default(); + state.enter(&domain); + state.observe(&domain.resource_label()); + state.observe(&private); + + assert!(!state + .publish(&domain, domain.audience(), domain.context(), &[7; 32], None,) + .allowed()); + } + + #[test] + fn ordinary_publication_stays_in_the_source_context() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let other_context = DomainContext::Conversation { + realm: realm(), + channel_id: Uuid::from_u128(2), + }; + let mut state = ProcessState::default(); + state.enter(&domain); + state.observe(&domain.resource_label()); + + assert!(!state + .publish(&domain, domain.audience(), &other_context, &[7; 32], None) + .allowed()); + } + + #[test] + fn equal_reader_sets_do_not_hide_cross_context_input() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let other = ResourceLabel::conversation(realm(), Uuid::from_u128(2), readers(&[1, 2])) + .expect("non-empty readers"); + let mut state = ProcessState::default(); + state.enter(&domain); + state.observe(&domain.resource_label()); + state.observe(&other); + + assert!(!state + .publish(&domain, domain.audience(), domain.context(), &[7; 32], None) + .allowed()); + } + + struct AlwaysValid; + + impl GrantSignatureVerifier for AlwaysValid { + fn verifies(&self, _grant: &DeclassificationGrant) -> bool { + true + } + } + + #[test] + fn declassification_is_owner_verified_and_exact() { + let owner = principal(1); + let domain = conversation(&[1], Uuid::from_u128(1), "v1"); + let destination = ConfidentialityLabel::public(realm()); + let destination_context = DomainContext::RealmPublic(realm()); + let content = [9; 32]; + let mut grant = DeclassificationGrant::pending( + owner.clone(), + domain.id(), + destination.clone(), + destination_context.clone(), + content, + ) + .verify(&owner, &AlwaysValid) + .expect("owner-authenticated grant"); + let mut state = ProcessState::default(); + state.enter(&domain); + state.observe(&domain.resource_label()); + + assert!(!state + .publish( + &domain, + &destination, + &destination_context, + &[8; 32], + Some(&mut grant), + ) + .allowed()); + assert!(state + .publish( + &domain, + &destination, + &destination_context, + &content, + Some(&mut grant), + ) + .allowed()); + assert!(!state + .publish( + &domain, + &destination, + &destination_context, + &content, + Some(&mut grant), + ) + .allowed()); + state.mark_unknown(); + assert!(!state + .publish( + &domain, + &destination, + &destination_context, + &content, + Some(&mut grant), + ) + .allowed()); + } + + #[test] + fn unknown_input_prevents_publication() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let mut state = ProcessState::default(); + state.enter(&domain); + state.observe(&domain.resource_label()); + state.mark_unknown(); + + assert!(!state + .publish(&domain, domain.audience(), domain.context(), &[7; 32], None,) + .allowed()); + } +} diff --git a/crates/buzz-ifc/src/policy.rs b/crates/buzz-ifc/src/policy.rs new file mode 100644 index 00000000000..4444dd4a17e --- /dev/null +++ b/crates/buzz-ifc/src/policy.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeSet; + +use serde::Serialize; + +use crate::declassification::{DeclassificationGrant, VerifiedGrant}; +use crate::domain::{DomainContext, ExecutionDomain, ResourceContext, ResourceLabel}; +use crate::label::{ConfidentialityLabel, LabelError}; + +/// The result of evaluating one IFC rule. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct RuleDecision { + allowed: bool, + reason: &'static str, +} + +impl RuleDecision { + fn allow(reason: &'static str) -> Self { + Self { + allowed: true, + reason, + } + } + + fn deny(reason: &'static str) -> Self { + Self { + allowed: false, + reason, + } + } + + /// Whether the operation is admitted by policy. + pub fn allowed(&self) -> bool { + self.allowed + } + + /// Stable explanation intended for logs and operator diagnostics. + pub fn reason(&self) -> &'static str { + self.reason + } + + /// Return `allow` or `deny` for structured logs. + pub fn result(&self) -> &'static str { + if self.allowed { + "allow" + } else { + "deny" + } + } +} + +/// Pure IFC rule evaluation. +pub struct RuleEvaluator; + +impl RuleEvaluator { + /// Evaluate `read(D, x) ⇔ A(D) ⊆ R(x) ∧ ContextPolicy(D, x)`. + pub fn read(domain: &ExecutionDomain, resource: &ResourceLabel) -> RuleDecision { + if !resource.confidentiality.can_flow_to(&domain.audience) { + return RuleDecision::deny("destination audience includes an unauthorized reader"); + } + if !domain.context.permits(&resource.context) { + return RuleDecision::deny("resource belongs to a different context"); + } + RuleDecision::allow("audience and context both permit the read") + } + + /// Evaluate `call(D, op) ⇔ op ∈ C(D)`. + pub fn call(domain: &ExecutionDomain, operation: &str) -> RuleDecision { + if domain.capabilities.contains(operation) { + RuleDecision::allow("operation is in the effective capability set") + } else { + RuleDecision::deny("operation is absent from the effective capability set") + } + } + + /// Require every component of the execution domain to match for reuse. + pub fn reuse(existing: &ExecutionDomain, requested: &ExecutionDomain) -> RuleDecision { + if existing == requested { + RuleDecision::allow("complete execution domain matches") + } else { + RuleDecision::deny("agent process has already entered a different domain") + } + } +} + +#[derive(Clone, Debug, Default)] +struct ConfinementState { + label: Option, + contexts: BTreeSet, + unknown_input: bool, + cross_realm: bool, +} + +impl ConfinementState { + fn observe(&mut self, resource: &ResourceLabel) { + self.contexts.insert(resource.context.clone()); + self.label = match self.label.take() { + None => Some(resource.confidentiality.clone()), + Some(existing) => match existing.join(&resource.confidentiality) { + Ok(combined) => Some(combined), + Err(LabelError::CrossRealm) => { + self.cross_realm = true; + Some(existing) + } + Err(LabelError::EmptyReaderSet) => Some(existing), + }, + }; + } +} + +/// Conservative policy state for one actual agent process. +/// +/// Paper: "Confinement invariant." The accumulated label and observed contexts +/// cover every input that actually entered the process. +/// +/// This state intentionally survives model-session invalidation. A new session +/// does not make the surrounding process forget information it has observed. +#[derive(Clone, Debug, Default)] +pub struct ProcessState { + entered_domains: Vec, + confinement: ConfinementState, +} + +impl ProcessState { + /// Record entry into a domain and decide whether this process is reusable. + pub fn enter(&mut self, requested: &ExecutionDomain) -> RuleDecision { + let decision = match self.entered_domains.as_slice() { + [] => RuleDecision::allow("fresh process has no prior execution domain"), + [existing] => RuleEvaluator::reuse(existing, requested), + _ => RuleDecision::deny("agent process has entered multiple execution domains"), + }; + if !self + .entered_domains + .iter() + .any(|domain| domain == requested) + { + self.entered_domains.push(requested.clone()); + } + decision + } + + /// Record an input that actually entered the process. + pub fn observe(&mut self, resource: &ResourceLabel) { + self.confinement.observe(resource); + } + + /// Record input whose provenance could not be established. + pub fn mark_unknown(&mut self) { + self.confinement.unknown_input = true; + } + + /// Return how many distinct domains have entered this process. + pub fn entered_domain_count(&self) -> usize { + self.entered_domains.len() + } + + /// Paper: "Confinement invariant" and "Declassification." Evaluate + /// publication under the process's accumulated label. + pub fn publish( + &self, + source_domain: &ExecutionDomain, + destination: &ConfidentialityLabel, + destination_context: &DomainContext, + content_digest: &[u8; 32], + grant: Option<&mut DeclassificationGrant>, + ) -> RuleDecision { + if self.entered_domains.len() != 1 || self.entered_domains.first() != Some(source_domain) { + return RuleDecision::deny( + "process state is not confined to the claimed source domain", + ); + } + if self.confinement.unknown_input || self.confinement.cross_realm { + return RuleDecision::deny("process state contains unresolved input provenance"); + } + if self + .confinement + .contexts + .iter() + .any(|context| !source_domain.context.permits(context)) + { + return RuleDecision::deny("process state contains input from another context"); + } + if grant.is_some_and(|grant| { + grant.matches( + &source_domain.id(), + destination, + destination_context, + content_digest, + ) + }) { + return RuleDecision::allow("exact owner-authorized declassification grant matches"); + } + if &source_domain.context != destination_context { + return RuleDecision::deny( + "publishing to a different context requires declassification", + ); + } + if !source_domain.audience.can_flow_to(destination) { + return RuleDecision::deny("destination is broader than the execution domain"); + } + if self + .confinement + .label + .as_ref() + .is_some_and(|label| label.can_flow_to(destination)) + { + return RuleDecision::allow("destination is no broader than accumulated state"); + } + RuleDecision::deny("output would widen the accumulated reader set") + } +}