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:
7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz
2026-07-23 12:42:32 -07:00
parent 0309f9e071
commit 1a328cfa8b
6 changed files with 185 additions and 19 deletions
+4 -1
View File
@@ -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
+1 -8
View File
@@ -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"),
+15 -1
View File
@@ -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()?;
+3 -1
View File
@@ -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)