Expose IFC policy to external agent harnesses

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-08-18 16:51:52 -07:00
parent bd03a4cc90
commit 8944041879
12 changed files with 1131 additions and 5 deletions

View File

@@ -87,7 +87,8 @@ buzz-core (zero I/O — types, verification, filter matching, kind registry)
└── buzz-relay (ties everything together — the server)
buzz-ifc (zero I/O — execution-domain derivation and IFC rules)
└── buzz-acp (relay/ACP adapter and worker routing)
├── buzz-acp (relay/ACP adapter and worker routing)
└── buzz-ifc-broker (stateful JSON-RPC policy process for non-Rust harnesses)
buzz-sdk (typed Nostr event builders — used by buzz-acp and buzz-cli)
buzz-media (Blossom/S3 media storage)
buzz-cli (agent-first CLI)
@@ -677,11 +678,11 @@ Buzz Relay ──WS──→ buzz-acp ──stdio (ACP/JSON-RPC)──→ Agent
---
### buzz-ifc — Shared Agent Policy
### buzz-ifc and buzz-ifc-broker — Shared Agent Policy
`buzz-ifc` is a zero-I/O policy crate. Given facts already verified by a trusted Buzz adapter, it derives the invocation's audience, retained-state context, membership epoch, and effective capabilities. It also evaluates reads, calls, publications, and process reuse while retaining a conservative label for each process.
`buzz-acp` links the crate directly. The surrounding adapter remains responsible for signed-event and membership verification, process lifecycle, credential mediation, and OS or VM confinement for confidential domains.
`buzz-acp` links the crate directly. Other harnesses can run `buzz-ifc-broker` and use its bounded JSON-RPC protocol over stdio. Despite the executable name, this is the policy process inside a larger trusted agent gateway, not the complete product broker. It retains policy state for workers on that connection. The surrounding adapter remains responsible for signed-event and membership verification, process lifecycle, credential mediation, and OS or VM confinement for confidential domains.
---

18
Cargo.lock generated
View File

@@ -1132,6 +1132,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "buzz-ifc-broker"
version = "0.1.0"
dependencies = [
"anyhow",
"buzz-ifc",
"clap",
"futures-util",
"hex",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "buzz-media"
version = "0.1.0"

View File

@@ -11,6 +11,7 @@ members = [
"crates/buzz-audit",
"crates/buzz-acp",
"crates/buzz-ifc",
"crates/buzz-ifc-broker",
"crates/buzz-agent",
"crates/sprig",
"crates/buzz-test-client",

View File

@@ -227,7 +227,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH
**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace.
**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-ifc` (shared audience-scoped agent policy) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs)
**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-ifc` / `buzz-ifc-broker` (shared audience-scoped agent policy) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs)
**Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `buzz-pair-relay` / `buzz-pairing-cli` (relay pairing)

View File

@@ -153,7 +153,9 @@ state. Its logs report those remaining gaps explicitly.
The deterministic label, domain, capability, reuse, and confinement rules live
in the runtime-independent `buzz-ifc` crate. `buzz-acp` supplies the trusted
Buzz-specific half: signed-event admission, relay-signed membership resolution,
and ACP worker replacement.
and ACP worker replacement. A non-Rust harness can use the same rules through
the bounded JSON-RPC interface provided by `buzz-ifc-broker`; see that crate's
README and conformance fixture for the wire contract.
### Parallel Agents & Heartbeat

View File

@@ -0,0 +1,22 @@
[package]
name = "buzz-ifc-broker"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Language-neutral policy broker for audience-scoped Buzz agents"
[dependencies]
anyhow = { workspace = true }
buzz-ifc = { workspace = true }
clap = { version = "4", features = ["derive", "env"] }
futures-util = { workspace = true }
hex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-std"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,77 @@
# buzz-ifc-broker
`buzz-ifc-broker` exposes the shared `buzz-ifc` policy over bounded JSON-RPC
2.0 on stdin/stdout. It exists for trusted agent harnesses that cannot link the
Rust crate directly, such as a Go kgoose integration.
The broker owns process-level IFC state. The caller supplies facts only after
it has verified the triggering event and authoritative Buzz membership. The
caller does not choose an audience or context: `buzz-ifc` derives both from the
verified conversation kind, roster, requesters, executing agent, and owner.
The agent runtime must not receive raw Buzz credentials or another route around
the trusted adapter. Despite its executable name, this is the policy process
inside the agent gateway, not the complete product broker. It does not launch,
sandbox, or terminate workers and cannot force its caller to obey a decision.
Every derived domain includes a compartment profile:
- `shared_public` may reuse the realm's normal public worker and public state.
- `domain_confined` requires a worker, writable state, and output paths dedicated
to that exact restricted or owner-private domain.
The trusted adapter implements that placement. A local sandbox, container, or VM
supplies the final confinement boundary for `domain_confined` work.
Run in compatibility mode:
```bash
buzz-ifc-broker --mode audit
```
Run when the trusted adapter will treat denied decisions as blocking:
```bash
buzz-ifc-broker --mode enforce
```
Each request and response is one JSON line. Start by binding a concrete worker
to the domain derived from verified invocation facts:
```json
{"jsonrpc":"2.0","id":1,"method":"worker/enter","params":{"worker_id":"kgoose-session-42","invocation":{"realm_url":"wss://buzz.example","channel_id":"00000000-0000-0000-0000-000000000001","conversation_kind":"restricted","epoch":"membership:<signed-event-id>","members":["<agent-pubkey>","<alice-pubkey>","<bob-pubkey>"],"executing_agent":"<agent-pubkey>","requesters":["<alice-pubkey>"],"owner":"<alice-pubkey>","bot_capabilities":["buzz.read.current","buzz.publish.current","email.read"],"conversation_capabilities":["buzz.read.current","buzz.publish.current"]}}}
```
The result includes the opaque `domain_id`; `details.replace_worker` tells the
adapter when the named process already contains another domain. The adapter
must retire that process before delivering the new request.
Call `worker/observe` before labeled data enters the process and `worker/call`
before invoking a mediated operation. If audit mode proceeds after a denied
call, its result must still be reported with `worker/observe`. Before Buzz signs
or sends a response, call `worker/publish` with the exact content digest and the
actual destination. Without a verified declassification grant, output is bound
to the source context. The current protocol intentionally exposes no
declassification method.
Supported methods:
- `broker/info`
- `domain/derive`
- `worker/enter`
- `worker/observe`
- `worker/call`
- `worker/publish`
- `worker/retire`
Logs are written to stderr. Stdout contains protocol frames only.
The broker connection is part of the worker lifecycle. `worker/retire` removes
policy state only after the adapter has terminated the corresponding process.
If the broker exits or the connection is replaced, the adapter must terminate
all workers tracked by that broker. A fresh broker has no evidence that an
existing process is safe to reuse.
`tests/fixtures/domain-golden.json` is the language-neutral conformance vector.
Adapters should replay it in their own test suite rather than duplicating the
domain hashing algorithm.

View File

@@ -0,0 +1,842 @@
//! Stateful, language-neutral access to [`buzz_ifc`] policy decisions.
//!
//! The broker accepts bounded JSON-RPC requests from a trusted Buzz adapter.
//! That adapter remains responsible for authenticating events and resolving
//! authoritative membership. The broker derives the execution domain from
//! those verified facts, retains process-level confinement state, and returns
//! decisions that a local ACP bridge or remote harness can enforce consistently.
use std::collections::{BTreeSet, HashMap};
use std::fmt;
use buzz_ifc::{
derive_execution_domain, CapabilityPolicy, CapabilitySet, ConfidentialityLabel,
ConversationKind, DomainContext, DomainFacts, ExecutionDomain, MembershipEpoch, Principal,
ProcessState, RealmId, ResourceLabel, RuleDecision, RuleEvaluator,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
/// Version of the JSON-RPC contract implemented by this broker.
pub const PROTOCOL_VERSION: &str = "buzz-ifc-broker/1";
/// Whether denied decisions are observational or enforced by the caller.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EnforcementMode {
/// Return and log decisions while conservatively tracking inputs that
/// entered the worker despite a denial.
Audit,
/// Require the caller to stop denied reads, calls, publications, and
/// cross-domain worker reuse.
Enforce,
}
impl EnforcementMode {
fn enforced(self) -> bool {
matches!(self, Self::Enforce)
}
}
impl fmt::Display for EnforcementMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Audit => f.write_str("audit"),
Self::Enforce => f.write_str("enforce"),
}
}
}
/// Stateful policy broker. One instance serves one trusted adapter stream.
pub struct Broker {
mode: EnforcementMode,
workers: HashMap<String, WorkerRecord>,
}
impl Broker {
/// Construct an empty broker in the selected enforcement mode.
pub fn new(mode: EnforcementMode) -> Self {
Self {
mode,
workers: HashMap::new(),
}
}
/// Parse one JSON-RPC line and return one JSON-RPC response line.
pub fn handle_line(&mut self, line: &str) -> String {
let response = match serde_json::from_str::<RpcRequest>(line) {
Ok(request) => self.handle_request(request),
Err(error) => RpcResponse::error(
Value::Null,
-32700,
"parse error",
Some(json!({ "detail": error.to_string() })),
),
};
match serde_json::to_string(&response) {
Ok(encoded) => encoded,
Err(_) => {
"{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"response encoding failed\"}}".to_string()
}
}
}
/// Return a JSON-RPC error for an input frame rejected before JSON parsing.
pub fn frame_error(message: &str) -> String {
let response = RpcResponse::error(Value::Null, -32600, message, None);
match serde_json::to_string(&response) {
Ok(encoded) => encoded,
Err(_) => {
"{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"internal error\"}}".to_string()
}
}
}
fn handle_request(&mut self, request: RpcRequest) -> RpcResponse {
let id = match request.id {
Some(id) => id,
None => {
return RpcResponse::error(
Value::Null,
-32600,
"security decisions require a request id",
None,
)
}
};
if request.jsonrpc != "2.0" {
return RpcResponse::error(id, -32600, "jsonrpc must equal 2.0", None);
}
let method = request.method.clone();
let outcome = self.dispatch(&request.method, request.params);
match outcome {
Ok(result) => {
tracing::info!(
target: "buzz_ifc_broker::decision",
method,
mode = %self.mode,
outcome = "ok",
"IFC broker request evaluated"
);
RpcResponse::success(id, result)
}
Err(error) => {
tracing::info!(
target: "buzz_ifc_broker::decision",
method,
mode = %self.mode,
outcome = "error",
code = error.code,
reason = error.message,
"IFC broker request rejected"
);
RpcResponse::error(id, error.code, error.message, error.data)
}
}
}
fn dispatch(&mut self, method: &str, params: Value) -> Result<Value, BrokerError> {
match method {
"broker/info" => Ok(json!({
"protocol_version": PROTOCOL_VERSION,
"mode": self.mode.to_string(),
"worker_count": self.workers.len(),
})),
"domain/derive" => {
let params: DeriveParams = parse_params(params)?;
let domain = params.invocation.resolve()?;
Ok(domain_description(&domain))
}
"worker/enter" => {
let params: EnterParams = parse_params(params)?;
validate_worker_id(&params.worker_id)?;
let domain = params.invocation.resolve()?;
let domain_id = domain.id();
let compartment = domain.compartment_profile().as_str();
let record = self.workers.entry(params.worker_id.clone()).or_default();
let decision = record.state.enter(&domain);
let replace_worker = !decision.allowed();
if replace_worker {
record.state.mark_unknown();
if self.mode.enforced() {
record.poisoned = true;
} else {
record.domain = Some(domain);
}
} else {
record.domain = Some(domain);
}
Ok(decision_value(
self.mode,
"reuse",
&decision,
Some(&params.worker_id),
Some(&domain_id),
json!({
"replace_worker": replace_worker,
"compartment": compartment,
}),
))
}
"worker/observe" => {
let params: ObserveParams = parse_params(params)?;
let mode = self.mode;
let record = active_worker_mut(&mut self.workers, &params.worker_id)?;
if record.poisoned {
return Ok(poisoned_decision(mode, &params.worker_id, record));
}
let domain = record.domain.as_ref().ok_or_else(|| {
BrokerError::state("worker has not entered an execution domain")
})?;
let resource = params.resource.resolve(domain)?;
let decision = RuleEvaluator::read(domain, &resource);
if decision.allowed() || !mode.enforced() {
record.state.observe(&resource);
}
Ok(decision_value(
mode,
"read",
&decision,
Some(&params.worker_id),
Some(&domain.id()),
json!({ "source": params.source }),
))
}
"worker/call" => {
let params: CallParams = parse_params(params)?;
let record = active_worker(&self.workers, &params.worker_id)?;
if record.poisoned {
return Ok(poisoned_decision(self.mode, &params.worker_id, record));
}
let domain = record.domain.as_ref().ok_or_else(|| {
BrokerError::state("worker has not entered an execution domain")
})?;
let decision = RuleEvaluator::call(domain, &params.operation);
Ok(decision_value(
self.mode,
"call",
&decision,
Some(&params.worker_id),
Some(&domain.id()),
json!({ "operation": params.operation }),
))
}
"worker/publish" => {
let params: PublishParams = parse_params(params)?;
let record = active_worker(&self.workers, &params.worker_id)?;
if record.poisoned {
return Ok(poisoned_decision(self.mode, &params.worker_id, record));
}
let domain = record.domain.as_ref().ok_or_else(|| {
BrokerError::state("worker has not entered an execution domain")
})?;
let (audience, context) = params.destination.resolve(domain)?;
let digest = parse_digest(&params.content_sha256)?;
let decision = record
.state
.publish(domain, &audience, &context, &digest, None);
Ok(decision_value(
self.mode,
"publish",
&decision,
Some(&params.worker_id),
Some(&domain.id()),
json!({ "destination": context.kind() }),
))
}
"worker/retire" => {
let params: WorkerParams = parse_params(params)?;
validate_worker_id(&params.worker_id)?;
Ok(json!({
"worker_id": params.worker_id,
"retired": self.workers.remove(&params.worker_id).is_some(),
}))
}
_ => Err(BrokerError::method_not_found()),
}
}
}
#[derive(Default)]
struct WorkerRecord {
state: ProcessState,
domain: Option<ExecutionDomain>,
poisoned: bool,
}
#[derive(Deserialize)]
struct RpcRequest {
jsonrpc: String,
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Serialize)]
struct RpcResponse {
jsonrpc: &'static str,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RpcErrorBody>,
}
impl RpcResponse {
fn success(id: Value, result: Value) -> Self {
Self {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
fn error(id: Value, code: i64, message: &str, data: Option<Value>) -> Self {
Self {
jsonrpc: "2.0",
id,
result: None,
error: Some(RpcErrorBody {
code,
message: message.to_string(),
data,
}),
}
}
}
#[derive(Serialize)]
struct RpcErrorBody {
code: i64,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<Value>,
}
struct BrokerError {
code: i64,
message: &'static str,
data: Option<Value>,
}
impl BrokerError {
fn invalid_params(detail: impl Into<String>) -> Self {
Self {
code: -32602,
message: "invalid params",
data: Some(json!({ "detail": detail.into() })),
}
}
fn state(detail: impl Into<String>) -> Self {
Self {
code: -32001,
message: "invalid broker state",
data: Some(json!({ "detail": detail.into() })),
}
}
fn method_not_found() -> Self {
Self {
code: -32601,
message: "method not found",
data: None,
}
}
}
fn parse_params<T: for<'de> Deserialize<'de>>(params: Value) -> Result<T, BrokerError> {
serde_json::from_value(params).map_err(|error| BrokerError::invalid_params(error.to_string()))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DeriveParams {
invocation: InvocationSpec,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EnterParams {
worker_id: String,
invocation: InvocationSpec,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ObserveParams {
worker_id: String,
source: String,
resource: ResourceSpec,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CallParams {
worker_id: String,
operation: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PublishParams {
worker_id: String,
content_sha256: String,
destination: DestinationSpec,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkerParams {
worker_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InvocationSpec {
realm_url: String,
channel_id: Uuid,
conversation_kind: ConversationKindSpec,
epoch: String,
#[serde(default)]
members: Vec<String>,
executing_agent: String,
requesters: Vec<String>,
system_principal: Option<String>,
owner: Option<String>,
#[serde(default)]
bot_capabilities: Vec<String>,
#[serde(default)]
conversation_capabilities: Vec<String>,
}
impl InvocationSpec {
fn resolve(self) -> Result<ExecutionDomain, BrokerError> {
if self.realm_url.trim().is_empty() {
return Err(BrokerError::invalid_params("realm_url must not be empty"));
}
if self.epoch.trim().is_empty() {
return Err(BrokerError::invalid_params("epoch must not be empty"));
}
let policy = CapabilityPolicy::new(
CapabilitySet::from_names(self.bot_capabilities),
CapabilitySet::from_names(self.conversation_capabilities),
);
derive_execution_domain(
DomainFacts {
realm: RealmId::from_relay_url(&self.realm_url),
channel_id: self.channel_id,
kind: self.conversation_kind.into(),
epoch: MembershipEpoch::new(self.epoch),
members: resolve_principals(self.members)?,
executing_agent: parse_principal(&self.executing_agent)?,
requesters: resolve_principals(self.requesters)?,
system_principal: self
.system_principal
.as_deref()
.map(parse_principal)
.transpose()?,
owner: self.owner.as_deref().map(parse_principal).transpose()?,
},
&policy,
)
.map_err(|error| BrokerError::invalid_params(error.to_string()))
}
}
#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ConversationKindSpec {
Public,
Restricted,
DirectMessage,
}
impl From<ConversationKindSpec> for ConversationKind {
fn from(value: ConversationKindSpec) -> Self {
match value {
ConversationKindSpec::Public => Self::Public,
ConversationKindSpec::Restricted => Self::Restricted,
ConversationKindSpec::DirectMessage => Self::DirectMessage,
}
}
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum ResourceSpec {
Domain,
TrustedConfiguration,
Public,
Conversation {
channel_id: Uuid,
readers: Vec<String>,
},
OwnerPrivate {
owner: String,
},
}
impl ResourceSpec {
fn resolve(self, domain: &ExecutionDomain) -> Result<ResourceLabel, BrokerError> {
let realm = domain.audience().realm().clone();
match self {
Self::Domain => Ok(ResourceLabel::domain(domain)),
Self::TrustedConfiguration => Ok(ResourceLabel::trusted_configuration(realm)),
Self::Public => Ok(ResourceLabel::realm_public(realm)),
Self::Conversation {
channel_id,
readers,
} => ResourceLabel::conversation(realm, channel_id, resolve_principals(readers)?)
.map_err(|error| BrokerError::invalid_params(error.to_string())),
Self::OwnerPrivate { owner } => Ok(ResourceLabel::owner_private(
realm,
parse_principal(&owner)?,
)),
}
}
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum DestinationSpec {
Current,
Public,
Conversation {
channel_id: Uuid,
readers: Vec<String>,
},
OwnerPrivate {
owner: String,
},
}
impl DestinationSpec {
fn resolve(
self,
domain: &ExecutionDomain,
) -> Result<(ConfidentialityLabel, DomainContext), BrokerError> {
let realm = domain.audience().realm().clone();
match self {
Self::Current => Ok((domain.audience().clone(), domain.context().clone())),
Self::Public => Ok((
ConfidentialityLabel::public(realm.clone()),
DomainContext::RealmPublic(realm),
)),
Self::Conversation {
channel_id,
readers,
} => Ok((
ConfidentialityLabel::restricted(realm.clone(), resolve_principals(readers)?)
.map_err(|error| BrokerError::invalid_params(error.to_string()))?,
DomainContext::Conversation { realm, channel_id },
)),
Self::OwnerPrivate { owner } => {
let owner = parse_principal(&owner)?;
Ok((
ConfidentialityLabel::restricted(
realm.clone(),
BTreeSet::from([owner.clone()]),
)
.map_err(|error| BrokerError::invalid_params(error.to_string()))?,
DomainContext::OwnerPrivate { realm, owner },
))
}
}
}
}
fn resolve_principals(values: Vec<String>) -> Result<BTreeSet<Principal>, BrokerError> {
values
.into_iter()
.map(|value| parse_principal(&value))
.collect()
}
fn parse_principal(value: &str) -> Result<Principal, BrokerError> {
Principal::from_hex(value).map_err(|error| BrokerError::invalid_params(error.to_string()))
}
fn parse_digest(value: &str) -> Result<[u8; 32], BrokerError> {
let bytes = hex::decode(value)
.map_err(|error| BrokerError::invalid_params(format!("invalid content_sha256: {error}")))?;
bytes
.try_into()
.map_err(|_| BrokerError::invalid_params("content_sha256 must contain 32 bytes"))
}
fn validate_worker_id(value: &str) -> Result<(), BrokerError> {
let length = value.len();
if length == 0 || length > 256 {
return Err(BrokerError::invalid_params(
"worker_id must contain between 1 and 256 bytes",
));
}
Ok(())
}
fn active_worker<'a>(
workers: &'a HashMap<String, WorkerRecord>,
worker_id: &str,
) -> Result<&'a WorkerRecord, BrokerError> {
validate_worker_id(worker_id)?;
workers
.get(worker_id)
.ok_or_else(|| BrokerError::state("worker has not entered an execution domain"))
}
fn active_worker_mut<'a>(
workers: &'a mut HashMap<String, WorkerRecord>,
worker_id: &str,
) -> Result<&'a mut WorkerRecord, BrokerError> {
validate_worker_id(worker_id)?;
workers
.get_mut(worker_id)
.ok_or_else(|| BrokerError::state("worker has not entered an execution domain"))
}
fn domain_description(domain: &ExecutionDomain) -> Value {
json!({
"domain_id": domain.id(),
"domain_fingerprint": domain.key().fingerprint(),
"context": domain.context().kind(),
"compartment": domain.compartment_profile().as_str(),
"audience": if domain.audience().is_public() { "public" } else { "restricted" },
"reader_count": domain.audience().reader_count(),
"epoch_fingerprint": domain.epoch_fingerprint(),
})
}
fn decision_value(
mode: EnforcementMode,
rule: &'static str,
decision: &RuleDecision,
worker_id: Option<&str>,
domain_id: Option<&str>,
details: Value,
) -> Value {
tracing::info!(
target: "buzz_ifc_broker::decision",
rule,
decision = decision.result(),
reason = decision.reason(),
enforced = mode.enforced(),
worker_id,
domain_id,
"IFC broker security decision"
);
json!({
"decision": decision.result(),
"allowed": decision.allowed(),
"reason": decision.reason(),
"enforced": mode.enforced(),
"worker_id": worker_id,
"domain_id": domain_id,
"details": details,
})
}
fn poisoned_decision(mode: EnforcementMode, worker_id: &str, record: &WorkerRecord) -> Value {
let reason = "worker crossed an execution-domain boundary and must be retired";
let domain_id = record.domain.as_ref().map(ExecutionDomain::id);
tracing::info!(
target: "buzz_ifc_broker::decision",
rule = "reuse",
decision = "deny",
reason,
enforced = mode.enforced(),
worker_id,
domain_id,
"IFC broker security decision"
);
json!({
"decision": "deny",
"allowed": false,
"reason": reason,
"enforced": mode.enforced(),
"worker_id": worker_id,
"domain_id": domain_id,
"details": { "replace_worker": true },
})
}
#[cfg(test)]
mod tests {
use super::*;
const ALICE: &str = "0101010101010101010101010101010101010101010101010101010101010101";
const BOB: &str = "0202020202020202020202020202020202020202020202020202020202020202";
const AGENT: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
fn request(id: u64, method: &str, params: Value) -> String {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string()
}
fn restricted_invocation(epoch: &str) -> Value {
json!({
"realm_url": "wss://buzz.example",
"channel_id": "00000000-0000-0000-0000-000000000001",
"conversation_kind": "restricted",
"epoch": epoch,
"members": [AGENT, ALICE, BOB],
"executing_agent": AGENT,
"requesters": [ALICE],
"owner": ALICE,
"bot_capabilities": ["buzz.read.current", "email.read"],
"conversation_capabilities": ["buzz.read.current"]
})
}
fn response(line: &str) -> Value {
serde_json::from_str(line).expect("broker response")
}
#[test]
fn cross_domain_reuse_requires_worker_replacement() {
let mut broker = Broker::new(EnforcementMode::Enforce);
let first = response(&broker.handle_line(&request(
1,
"worker/enter",
json!({ "worker_id": "worker-1", "invocation": restricted_invocation("v1") }),
)));
assert_eq!(first["result"]["decision"], "allow");
assert_eq!(first["result"]["details"]["compartment"], "domain_confined");
let second = response(&broker.handle_line(&request(
2,
"worker/enter",
json!({ "worker_id": "worker-1", "invocation": restricted_invocation("v2") }),
)));
assert_eq!(second["result"]["decision"], "deny");
assert_eq!(second["result"]["details"]["replace_worker"], true);
let call = response(&broker.handle_line(&request(
3,
"worker/call",
json!({ "worker_id": "worker-1", "operation": "buzz.read.current" }),
)));
assert_eq!(call["result"]["decision"], "deny");
}
#[test]
fn owner_private_read_is_denied_in_a_conversation_domain() {
let mut broker = Broker::new(EnforcementMode::Enforce);
let _ = broker.handle_line(&request(
1,
"worker/enter",
json!({ "worker_id": "worker-1", "invocation": restricted_invocation("v1") }),
));
let read = response(&broker.handle_line(&request(
2,
"worker/observe",
json!({
"worker_id": "worker-1",
"source": "email",
"resource": { "kind": "owner_private", "owner": ALICE }
}),
)));
assert_eq!(read["result"]["decision"], "deny");
assert_eq!(read["result"]["enforced"], true);
}
#[test]
fn conversation_worker_is_bound_to_shared_tools_and_its_source_context() {
let mut broker = Broker::new(EnforcementMode::Enforce);
let _ = broker.handle_line(&request(
1,
"worker/enter",
json!({ "worker_id": "worker-1", "invocation": restricted_invocation("v1") }),
));
let call = response(&broker.handle_line(&request(
2,
"worker/call",
json!({ "worker_id": "worker-1", "operation": "email.read" }),
)));
assert_eq!(call["result"]["decision"], "deny");
let observe = response(&broker.handle_line(&request(
3,
"worker/observe",
json!({
"worker_id": "worker-1",
"source": "conversation_history",
"resource": { "kind": "domain" }
}),
)));
assert_eq!(observe["result"]["decision"], "allow");
let current = response(&broker.handle_line(&request(
4,
"worker/publish",
json!({
"worker_id": "worker-1",
"content_sha256": "00".repeat(32),
"destination": { "kind": "current" }
}),
)));
assert_eq!(current["result"]["decision"], "allow");
let other = response(&broker.handle_line(&request(
5,
"worker/publish",
json!({
"worker_id": "worker-1",
"content_sha256": "00".repeat(32),
"destination": {
"kind": "conversation",
"channel_id": "00000000-0000-0000-0000-000000000002",
"readers": [ALICE, BOB]
}
}),
)));
assert_eq!(other["result"]["decision"], "deny");
}
#[test]
fn malformed_verified_fact_fails_derivation_without_panicking() {
let mut broker = Broker::new(EnforcementMode::Audit);
let invalid = response(&broker.handle_line(&request(
1,
"domain/derive",
json!({
"invocation": {
"realm_url": "wss://buzz.example",
"channel_id": "00000000-0000-0000-0000-000000000001",
"conversation_kind": "direct_message",
"epoch": "v1",
"members": [AGENT, ALICE],
"executing_agent": AGENT,
"requesters": [ALICE],
"owner": "not-a-key"
}
}),
)));
assert_eq!(invalid["error"]["code"], -32602);
}
#[test]
fn public_domain_selects_the_shared_public_compartment() {
let mut broker = Broker::new(EnforcementMode::Audit);
let derived = response(&broker.handle_line(&request(
1,
"domain/derive",
json!({
"invocation": {
"realm_url": "wss://buzz.example",
"channel_id": "00000000-0000-0000-0000-000000000001",
"conversation_kind": "public",
"epoch": "community:v1",
"executing_agent": AGENT,
"requesters": [ALICE],
"owner": ALICE
}
}),
)));
assert_eq!(derived["result"]["compartment"], "shared_public");
}
}

View File

@@ -0,0 +1,69 @@
use std::io;
use anyhow::Context;
use buzz_ifc_broker::{Broker, EnforcementMode};
use clap::{Parser, ValueEnum};
use futures_util::StreamExt;
use tokio::io::{AsyncWriteExt, BufWriter};
use tokio_util::codec::{FramedRead, LinesCodec};
const MAX_REQUEST_BYTES: usize = 1024 * 1024;
#[derive(Parser)]
#[command(name = "buzz-ifc-broker", version, about)]
struct Args {
/// Whether decisions are observational or must be enforced by the adapter.
#[arg(long, env = "BUZZ_IFC_BROKER_MODE", default_value = "audit")]
mode: Mode,
}
#[derive(Clone, Copy, ValueEnum)]
enum Mode {
Audit,
Enforce,
}
impl From<Mode> for EnforcementMode {
fn from(value: Mode) -> Self {
match value {
Mode::Audit => Self::Audit,
Mode::Enforce => Self::Enforce,
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(io::stderr)
.try_init();
let mode = EnforcementMode::from(args.mode);
tracing::info!(%mode, "Buzz IFC broker started");
let mut broker = Broker::new(mode);
let stdin = tokio::io::stdin();
let mut frames = FramedRead::new(stdin, LinesCodec::new_with_max_length(MAX_REQUEST_BYTES));
let mut stdout = BufWriter::new(tokio::io::stdout());
while let Some(frame) = frames.next().await {
let response = match frame {
Ok(line) => broker.handle_line(&line),
Err(error) => {
tracing::warn!(%error, "Buzz IFC broker rejected an input frame");
Broker::frame_error("request frame exceeds the broker limit")
}
};
stdout
.write_all(response.as_bytes())
.await
.context("write broker response")?;
stdout.write_all(b"\n").await.context("write line ending")?;
stdout.flush().await.context("flush broker response")?;
}
Ok(())
}

View File

@@ -0,0 +1,22 @@
use buzz_ifc_broker::{Broker, EnforcementMode, PROTOCOL_VERSION};
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
struct Fixture {
protocol_version: String,
request: Value,
expected_result: Value,
}
#[test]
fn domain_golden_fixture_matches_the_wire_contract() {
let fixture: Fixture = serde_json::from_str(include_str!("fixtures/domain-golden.json"))
.expect("valid conformance fixture");
assert_eq!(fixture.protocol_version, PROTOCOL_VERSION);
let mut broker = Broker::new(EnforcementMode::Enforce);
let response: Value = serde_json::from_str(&broker.handle_line(&fixture.request.to_string()))
.expect("valid broker response");
assert_eq!(response["result"], fixture.expected_result);
}

View File

@@ -0,0 +1,37 @@
{
"protocol_version": "buzz-ifc-broker/1",
"request": {
"jsonrpc": "2.0",
"id": 1,
"method": "domain/derive",
"params": {
"invocation": {
"realm_url": "wss://buzz.example",
"channel_id": "00000000-0000-0000-0000-000000000001",
"conversation_kind": "restricted",
"epoch": "membership:event-1",
"members": [
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"0101010101010101010101010101010101010101010101010101010101010101",
"0202020202020202020202020202020202020202020202020202020202020202"
],
"executing_agent": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"requesters": [
"0101010101010101010101010101010101010101010101010101010101010101"
],
"owner": "0101010101010101010101010101010101010101010101010101010101010101",
"bot_capabilities": ["buzz.read.current"],
"conversation_capabilities": ["buzz.read.current"]
}
}
},
"expected_result": {
"audience": "restricted",
"compartment": "domain_confined",
"context": "conversation",
"domain_fingerprint": "77ed1593198b",
"domain_id": "fbad5ec7aed0f274f9c517fed619cb41262c2295fb62451477a3df7caf1f3ef1",
"epoch_fingerprint": "434d4af44419",
"reader_count": 2
}
}

View File

@@ -0,0 +1,35 @@
use std::io::Write;
use std::process::{Command, Stdio};
use serde_json::{json, Value};
#[test]
fn stdio_keeps_protocol_output_to_one_json_line() {
let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-ifc-broker"))
.args(["--mode", "enforce"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn broker");
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "broker/info",
"params": {}
});
child
.stdin
.take()
.expect("broker stdin")
.write_all(format!("{request}\n").as_bytes())
.expect("write request");
let output = child.wait_with_output().expect("broker output");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert_eq!(stdout.lines().count(), 1);
let response: Value = serde_json::from_str(stdout.trim()).expect("JSON-RPC response");
assert_eq!(response["result"]["mode"], "enforce");
assert_eq!(response["result"]["worker_count"], 0);
}