mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Close local-agent relay enrollment gaps
Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
This commit is contained in:
parent
0309f9e071
commit
1a328cfa8b
@@ -122,7 +122,10 @@ const overrides = new Map([
|
||||
// runtime log path. Load-bearing crash-recovery surface; queued to split.
|
||||
// internal-owner-only: persistence choke point normalizes local agent access.
|
||||
// internal-local-relay-lockdown: load/save rejects legacy disallowed pins.
|
||||
["src-tauri/src/managed_agents/storage.rs", 1390],
|
||||
// internal-local-relay-lockdown round 2: remediation-safe changed-pin checks and tests.
|
||||
["src-tauri/src/managed_agents/storage.rs", 1464],
|
||||
// internal-local-relay-lockdown round 2: guard initial and incremental huddle enrollment.
|
||||
["src-tauri/src/huddle/mod.rs", 1006],
|
||||
// harness-persona-sync: persona-runtime resolution threaded into the spawn
|
||||
// path here. Load-bearing feature growth; queued to split in the resolver
|
||||
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
|
||||
|
||||
@@ -791,14 +791,7 @@ pub async fn add_channel_members(
|
||||
let uuid = parse_channel_uuid(&channel_id)?;
|
||||
let relay_url = crate::relay::relay_ws_url_with_override(&state);
|
||||
let local_agents = crate::managed_agents::load_managed_agents(&app)?;
|
||||
for pubkey in &pubkeys {
|
||||
if let Some(record) = local_agents.iter().find(|record| {
|
||||
record.pubkey.eq_ignore_ascii_case(pubkey)
|
||||
&& record.backend == crate::managed_agents::BackendKind::Local
|
||||
}) {
|
||||
crate::managed_agents::validate_local_agent_relay(&record.backend, &relay_url)?;
|
||||
}
|
||||
}
|
||||
crate::managed_agents::validate_local_agent_members(&local_agents, &pubkeys, &relay_url)?;
|
||||
let role_str = match role.as_deref() {
|
||||
Some("admin") => Some("admin"),
|
||||
Some("bot") => Some("bot"),
|
||||
|
||||
@@ -67,7 +67,7 @@ pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline};
|
||||
// ── Imports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
use tauri::State;
|
||||
use tauri::{AppHandle, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{app_state::AppState, events, relay::submit_event};
|
||||
@@ -94,6 +94,16 @@ fn normalize_huddle_channel_name(candidate: Option<String>, fallback: &str) -> S
|
||||
name.chars().take(80).collect()
|
||||
}
|
||||
|
||||
fn validate_huddle_agent_enrollment(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkeys: &[String],
|
||||
) -> Result<(), String> {
|
||||
let records = crate::managed_agents::load_managed_agents(app)?;
|
||||
let relay_url = crate::relay::relay_ws_url_with_override(state);
|
||||
crate::managed_agents::validate_local_agent_members(&records, pubkeys, &relay_url)
|
||||
}
|
||||
|
||||
// ── Tauri commands ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Set the voice input mode (push-to-talk or voice-activity detection).
|
||||
@@ -162,6 +172,7 @@ pub async fn start_huddle(
|
||||
parent_channel_id: String,
|
||||
member_pubkeys: Vec<String>,
|
||||
channel_name: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HuddleJoinInfo, String> {
|
||||
// Validate inputs at the Tauri boundary.
|
||||
@@ -184,6 +195,7 @@ pub async fn start_huddle(
|
||||
}
|
||||
deduped
|
||||
};
|
||||
validate_huddle_agent_enrollment(&app, &state, &member_pubkeys)?;
|
||||
|
||||
// Transition to Creating.
|
||||
{
|
||||
@@ -925,9 +937,11 @@ pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Re
|
||||
#[tauri::command]
|
||||
pub async fn add_agent_to_huddle(
|
||||
agent_pubkey: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<agents::AgentAddResult, String> {
|
||||
validate_pubkey_hex(&agent_pubkey)?;
|
||||
validate_huddle_agent_enrollment(&app, &state, std::slice::from_ref(&agent_pubkey))?;
|
||||
|
||||
let (eph_id, parent_id) = {
|
||||
let hs = state.huddle()?;
|
||||
|
||||
@@ -11,7 +11,9 @@ pub(crate) use access_policy::{
|
||||
pub(crate) use agent_env::{
|
||||
baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,
|
||||
};
|
||||
pub(crate) use relay_policy::{validate_local_agent_relay, validate_managed_agent_relay_pin};
|
||||
pub(crate) use relay_policy::{
|
||||
validate_local_agent_members, validate_local_agent_relay, validate_managed_agent_relay_pin,
|
||||
};
|
||||
mod backend;
|
||||
pub(crate) mod config_bridge;
|
||||
mod discovery;
|
||||
|
||||
@@ -89,6 +89,36 @@ pub(crate) fn validate_managed_agent_relay_pin(record: &ManagedAgentRecord) -> R
|
||||
validate_local_agent_relay(&record.backend, &record.relay_url)
|
||||
}
|
||||
|
||||
/// Reject attachment of locally managed agents to a disallowed effective relay.
|
||||
/// Unknown pubkeys and provider-backed records are outside this policy.
|
||||
pub(crate) fn validate_local_agent_members(
|
||||
records: &[ManagedAgentRecord],
|
||||
pubkeys: &[String],
|
||||
relay_url: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_local_agent_members_with(records, pubkeys, |backend| {
|
||||
validate_local_agent_relay(backend, relay_url)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_local_agent_members_with<F>(
|
||||
records: &[ManagedAgentRecord],
|
||||
pubkeys: &[String],
|
||||
validate: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(&BackendKind) -> Result<(), String>,
|
||||
{
|
||||
for pubkey in pubkeys {
|
||||
if let Some(record) = records.iter().find(|record| {
|
||||
record.pubkey.eq_ignore_ascii_case(pubkey) && record.backend == BackendKind::Local
|
||||
}) {
|
||||
validate(&record.backend)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -97,6 +127,24 @@ mod tests {
|
||||
parse_allowlist(" WSS://Buzz.Block.Builderlab.XYZ:443/\n")
|
||||
}
|
||||
|
||||
fn record(pubkey: &str, backend: BackendKind) -> ManagedAgentRecord {
|
||||
let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({
|
||||
"pubkey": pubkey,
|
||||
"name": "test-agent",
|
||||
"relay_url": "",
|
||||
"acp_command": "buzz-acp",
|
||||
"agent_command": "goose",
|
||||
"agent_args": [],
|
||||
"mcp_command": "",
|
||||
"turn_timeout_seconds": 320,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}))
|
||||
.expect("sample record");
|
||||
record.backend = backend;
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_local_policy_matches_exact_normalized_origin() {
|
||||
assert!(validate_local_agent_relay_with_policy(
|
||||
@@ -138,6 +186,38 @@ mod tests {
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_enrollment_validates_only_matching_local_agents() {
|
||||
let provider = BackendKind::Provider {
|
||||
id: "provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
let records = vec![
|
||||
record("local", BackendKind::Local),
|
||||
record("provider", provider),
|
||||
];
|
||||
let calls = std::cell::Cell::new(0);
|
||||
|
||||
assert!(
|
||||
validate_local_agent_members_with(&records, &["LOCAL".into()], |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Err("blocked".to_string())
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(calls.get(), 1);
|
||||
assert!(validate_local_agent_members_with(
|
||||
&records,
|
||||
&["provider".into(), "unknown".into()],
|
||||
|_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Err("blocked".to_string())
|
||||
},
|
||||
)
|
||||
.is_ok());
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_policy_fails_closed_on_missing_empty_or_malformed_allowlist() {
|
||||
for allowlist in [
|
||||
|
||||
@@ -193,15 +193,18 @@ fn load_agent_store(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String>
|
||||
})
|
||||
}
|
||||
|
||||
fn retain_managed_agent_instances(records: &mut Vec<ManagedAgentRecord>) {
|
||||
records.retain(|record| !record.pubkey.is_empty());
|
||||
}
|
||||
|
||||
/// Load the keyed agent *instances*. Key-less definitions (former personas,
|
||||
/// folded into the same store) are filtered out so every pre-fold call site
|
||||
/// keeps seeing exactly the records it always did.
|
||||
/// keeps seeing exactly the records it always did. Relay-policy violations are
|
||||
/// enforced at save/attach/start boundaries, not here, so a bad legacy pin can
|
||||
/// still be listed, fixed, or deleted.
|
||||
pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, String> {
|
||||
let mut records = load_agent_store(app)?;
|
||||
records.retain(|record| !record.pubkey.is_empty());
|
||||
records
|
||||
.iter()
|
||||
.try_for_each(super::validate_managed_agent_relay_pin)?;
|
||||
retain_managed_agent_instances(&mut records);
|
||||
hydrate_keys(&mut records);
|
||||
Ok(records)
|
||||
}
|
||||
@@ -295,17 +298,49 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord])
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_changed_relay_pins_with<F>(
|
||||
records: &[ManagedAgentRecord],
|
||||
previous: &[ManagedAgentRecord],
|
||||
validate: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(&ManagedAgentRecord) -> Result<(), String>,
|
||||
{
|
||||
for record in records {
|
||||
let unchanged = previous.iter().any(|old| {
|
||||
old.pubkey == record.pubkey
|
||||
&& old.backend == record.backend
|
||||
&& old.relay_url == record.relay_url
|
||||
});
|
||||
if !unchanged {
|
||||
validate(record)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the keyed agent *instances*, preserving the key-less definitions that
|
||||
/// share the unified store: callers pass exactly the records they loaded via
|
||||
/// [`load_managed_agents`], and this re-reads the definition half from disk
|
||||
/// before the wholesale rewrite so a definition is never dropped by an
|
||||
/// instance-side save (and vice versa via [`save_agent_definitions`]).
|
||||
pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> {
|
||||
let definitions = load_agent_definitions(app).unwrap_or_default();
|
||||
let stored = load_agent_store(app).unwrap_or_default();
|
||||
let definitions = stored
|
||||
.iter()
|
||||
.filter(|record| record.pubkey.is_empty())
|
||||
.cloned()
|
||||
.collect();
|
||||
let previous_instances: Vec<_> = stored
|
||||
.into_iter()
|
||||
.filter(|record| !record.pubkey.is_empty())
|
||||
.collect();
|
||||
validate_changed_relay_pins_with(records, &previous_instances, |record| {
|
||||
super::validate_managed_agent_relay_pin(record)
|
||||
})?;
|
||||
let mut sorted = records.to_vec();
|
||||
for record in &mut sorted {
|
||||
super::normalize_managed_agent_access(record);
|
||||
super::validate_managed_agent_relay_pin(record)?;
|
||||
}
|
||||
// A caller-supplied key-less record would collide with the definition
|
||||
// half re-read below; instances always carry a pubkey.
|
||||
@@ -802,7 +837,8 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with,
|
||||
KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord,
|
||||
retain_managed_agent_instances, validate_changed_relay_pins_with, KeyMigration, KeyStore,
|
||||
KeyringProbe, ManagedAgentRecord,
|
||||
};
|
||||
|
||||
/// In-memory [`KeyStore`] for testing the migrate decision without the OS
|
||||
@@ -932,6 +968,44 @@ mod tests {
|
||||
.expect("sample record")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_filter_keeps_legacy_pins_available_for_remediation() {
|
||||
let mut pinned = record_with_key("nsec1realkey");
|
||||
pinned.relay_url = "wss://public.example".into();
|
||||
let mut definition = pinned.clone();
|
||||
definition.pubkey.clear();
|
||||
let mut records = vec![pinned.clone(), definition];
|
||||
|
||||
retain_managed_agent_instances(&mut records);
|
||||
|
||||
assert_eq!(records, vec![pinned]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_bad_pin_allows_unrelated_save_but_changed_pin_is_validated() {
|
||||
let mut pinned = record_with_key("nsec1realkey");
|
||||
pinned.relay_url = "wss://public.example".into();
|
||||
let previous = vec![pinned.clone()];
|
||||
let calls = std::cell::Cell::new(0);
|
||||
|
||||
assert!(
|
||||
validate_changed_relay_pins_with(&[pinned.clone()], &previous, |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Err("blocked".into())
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(calls.get(), 0);
|
||||
|
||||
pinned.relay_url = "wss://other.example".into();
|
||||
assert!(validate_changed_relay_pins_with(&[pinned], &previous, |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Err("blocked".into())
|
||||
})
|
||||
.is_err());
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_persists_and_signals_stripping_when_keyring_reachable() {
|
||||
// Item 2: an inline key (residue from a prior keyring-unreachable save)
|
||||
|
||||
Reference in New Issue
Block a user