mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
B5 residue: inbound-event security hardening + persona→agent copy sweep (#1688)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -463,35 +463,54 @@ fn inbound_team_no_match_inserts_idempotently() {
|
||||
// ── Tombstone (kind:5) consume ────────────────────────────────────────────
|
||||
|
||||
fn deletion_event(coord: &str) -> nostr::Event {
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
deletion_event_with_keys(coord, &nostr::Keys::generate())
|
||||
}
|
||||
|
||||
fn deletion_event_with_keys(coord: &str, keys: &nostr::Keys) -> nostr::Event {
|
||||
use nostr::{EventBuilder, JsonUtil, Kind, Tag};
|
||||
let event = EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![Tag::parse(["a", coord]).unwrap()])
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
nostr::Event::from_json(event.as_json()).unwrap()
|
||||
}
|
||||
|
||||
/// A deletion event whose coordinate owner IS its signer — the only shape
|
||||
/// `parse_deletion_coordinate` accepts since the owner check landed.
|
||||
fn owned_deletion_event(kind: u32, d_tag: &str) -> nostr::Event {
|
||||
let keys = nostr::Keys::generate();
|
||||
let owner = keys.public_key().to_hex();
|
||||
deletion_event_with_keys(&format!("{kind}:{owner}:{d_tag}"), &keys)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_deletion_coordinate_extracts_kind_and_d_tag() {
|
||||
let owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||||
// Persona / team / agent coordinates all route by their leading kind.
|
||||
let p = deletion_event(&format!("30175:{owner}:my-persona"));
|
||||
let p = owned_deletion_event(30175, "my-persona");
|
||||
assert_eq!(
|
||||
parse_deletion_coordinate(&p),
|
||||
Some((30175, "my-persona".to_string()))
|
||||
);
|
||||
let a = deletion_event(&format!("30177:{owner}:agentpubkeyhex"));
|
||||
let a = owned_deletion_event(30177, "agentpubkeyhex");
|
||||
assert_eq!(
|
||||
parse_deletion_coordinate(&a),
|
||||
Some((30177, "agentpubkeyhex".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_deletion_coordinate_rejects_foreign_owner() {
|
||||
// A validly signed kind:5 naming ANOTHER owner's coordinate must no-op:
|
||||
// NIP-09 scopes deletion to the record's own author.
|
||||
let foreign_owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||||
let forged = deletion_event(&format!("30175:{foreign_owner}:my-persona"));
|
||||
assert_eq!(parse_deletion_coordinate(&forged), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_deletion_coordinate_handles_colon_in_d_tag_and_rejects_malformed() {
|
||||
let owner = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||||
// A d-tag containing ':' keeps its remainder intact (splitn(3)).
|
||||
let weird = deletion_event(&format!("30176:{owner}:a:b:c"));
|
||||
let weird = owned_deletion_event(30176, "a:b:c");
|
||||
assert_eq!(
|
||||
parse_deletion_coordinate(&weird),
|
||||
Some((30176, "a:b:c".to_string()))
|
||||
@@ -528,3 +547,45 @@ fn tombstone_removal_predicates_match_apply_fn_keys() {
|
||||
agents.retain(|r| r.pubkey != AGENT_PUBKEY);
|
||||
assert!(agents.is_empty(), "agent removed by pubkey");
|
||||
}
|
||||
|
||||
// ── Inbound signature gate ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn inbound_gate_rejects_tampered_event() {
|
||||
use nostr::JsonUtil;
|
||||
// A validly signed event whose content was altered post-signing: the
|
||||
// pubkey is real, the sig no longer covers the bytes. Must die at the
|
||||
// gate before any store logic runs.
|
||||
let keys = nostr::Keys::generate();
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}")
|
||||
.tags(vec![nostr::Tag::parse(["d", "victim-slug"]).unwrap()])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let tampered = event.as_json().replace(
|
||||
"\"content\":\"{}\"",
|
||||
"\"content\":\"{\\\"system_prompt\\\":\\\"pwned\\\"}\"",
|
||||
);
|
||||
assert_ne!(
|
||||
tampered,
|
||||
event.as_json(),
|
||||
"string replace must have taken effect — if this fails the test is testing an un-tampered event"
|
||||
);
|
||||
|
||||
let err = parse_verified_inbound_event(&tampered).unwrap_err();
|
||||
assert!(
|
||||
err.contains("signature"),
|
||||
"tampered event must fail the signature gate: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inbound_gate_accepts_validly_signed_event() {
|
||||
use nostr::JsonUtil;
|
||||
let keys = nostr::Keys::generate();
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(30175), "{}")
|
||||
.tags(vec![nostr::Tag::parse(["d", "slug"]).unwrap()])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let parsed = parse_verified_inbound_event(&event.as_json()).unwrap();
|
||||
assert_eq!(parsed.pubkey, keys.public_key());
|
||||
}
|
||||
|
||||
@@ -145,10 +145,10 @@ pub async fn update_persona(
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} not found", input.id))?;
|
||||
.ok_or_else(|| format!("agent {} not found", input.id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be edited.".to_string());
|
||||
return Err("Built-in agents cannot be edited.".to_string());
|
||||
}
|
||||
|
||||
// Track whether avatar changed so we can sync linked agents.
|
||||
@@ -177,7 +177,7 @@ pub async fn update_persona(
|
||||
let result = personas
|
||||
.into_iter()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
|
||||
.ok_or_else(|| format!("agent {} disappeared unexpectedly", input.id))?;
|
||||
|
||||
// For pack-backed personas, also write the edit back to the source
|
||||
// `.persona.md` so that launch sync (which reads the file) becomes a
|
||||
@@ -291,7 +291,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
.ok_or_else(|| format!("agent {id} not found"))?;
|
||||
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
|
||||
team.persona_ids
|
||||
.iter()
|
||||
@@ -306,7 +306,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
|
||||
let original_len = personas.len();
|
||||
personas.retain(|record| record.id != id);
|
||||
if personas.len() == original_len {
|
||||
return Err(format!("persona {id} not found"));
|
||||
return Err(format!("agent {id} not found"));
|
||||
}
|
||||
save_personas(&app, &personas)?;
|
||||
tombstone_persona_pending(&app, &state, &d_tag);
|
||||
@@ -383,8 +383,7 @@ fn reconcile_inbound_persona_event_blocking(
|
||||
use nostr::JsonUtil;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
let event = nostr::Event::from_json(&event_json)
|
||||
.map_err(|e| format!("failed to parse inbound event: {e}"))?;
|
||||
let event = parse_verified_inbound_event(&event_json)?;
|
||||
|
||||
// The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5
|
||||
// (NIP-09 deletions). d-tags are NOT unique across kinds, so every path
|
||||
@@ -474,6 +473,21 @@ fn reconcile_inbound_persona_event_blocking(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an inbound wire event and enforce the signature gate. Everything
|
||||
/// downstream trusts `event.pubkey` (ownership routing, tombstone scoping,
|
||||
/// behavioral-quad application), so a forged pubkey must die here — the
|
||||
/// TS-side owner filter reads the same attacker-controlled field and is no
|
||||
/// defense.
|
||||
fn parse_verified_inbound_event(event_json: &str) -> Result<nostr::Event, String> {
|
||||
use nostr::JsonUtil;
|
||||
let event = nostr::Event::from_json(event_json)
|
||||
.map_err(|e| format!("failed to parse inbound event: {e}"))?;
|
||||
event
|
||||
.verify()
|
||||
.map_err(|e| format!("inbound event failed signature verification: {e}"))?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Parse a NIP-09 `a`-tag coordinate `<kind>:<owner_pubkey>:<d_tag>` into its
|
||||
/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so
|
||||
/// the caller no-ops on a tombstone it can't route.
|
||||
@@ -488,7 +502,14 @@ fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> {
|
||||
// most twice and keep the remainder as the d_tag.
|
||||
let mut parts = coord.splitn(3, ':');
|
||||
let kind: u32 = parts.next()?.parse().ok()?;
|
||||
let _owner = parts.next()?;
|
||||
let owner = parts.next()?;
|
||||
// NIP-09 scoping: only the record's author may tombstone it. The
|
||||
// signature gate upstream proves `event.pubkey`; requiring the
|
||||
// coordinate owner to match closes the other half — a validly
|
||||
// signed kind:5 naming ANOTHER owner's coordinate must no-op.
|
||||
if owner != event.pubkey.to_hex() {
|
||||
return None;
|
||||
}
|
||||
let d_tag = parts.next()?;
|
||||
Some((kind, d_tag.to_string()))
|
||||
})
|
||||
@@ -715,7 +736,7 @@ pub async fn set_persona_active(
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
.ok_or_else(|| format!("agent {id} not found"))?;
|
||||
|
||||
let referenced_by_managed_agent = !active
|
||||
&& load_managed_agents(&app)?
|
||||
@@ -860,7 +881,7 @@ pub async fn export_persona_to_json(
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
.ok_or_else(|| format!("agent {id} not found"))?;
|
||||
(
|
||||
persona.display_name.clone(),
|
||||
persona.system_prompt.clone(),
|
||||
|
||||
@@ -378,7 +378,7 @@ pub fn ensure_persona_is_active(
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|candidate| candidate.id == persona_id)
|
||||
.ok_or_else(|| format!("persona {persona_id} not found"))?;
|
||||
.ok_or_else(|| format!("agent {persona_id} not found"))?;
|
||||
|
||||
if !persona.is_active {
|
||||
return Err(format!(
|
||||
@@ -406,12 +406,12 @@ pub fn validate_persona_deletion(
|
||||
referenced_by_team: bool,
|
||||
) -> Result<(), String> {
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be deleted.".to_string());
|
||||
return Err("Built-in agents cannot be deleted.".to_string());
|
||||
}
|
||||
|
||||
if persona.source_team.is_some() {
|
||||
return Err(format!(
|
||||
"{} belongs to a team. Delete the team to remove all team personas together.",
|
||||
"{} belongs to a team. Delete the team to remove all team agents together.",
|
||||
persona.display_name
|
||||
));
|
||||
}
|
||||
@@ -433,9 +433,7 @@ pub fn validate_persona_activation_change(
|
||||
referenced_by_team: bool,
|
||||
) -> Result<(), String> {
|
||||
if !persona.is_builtin {
|
||||
return Err(
|
||||
"Only built-in personas can be added to or removed from My Agents.".to_string(),
|
||||
);
|
||||
return Err("Only built-in agents can be added to or removed from My Agents.".to_string());
|
||||
}
|
||||
|
||||
if !active && referenced_by_managed_agent {
|
||||
|
||||
@@ -215,7 +215,7 @@ fn merge_personas_demotes_retired_builtins() {
|
||||
fn ensure_persona_is_active_rejects_missing_personas() {
|
||||
let err = ensure_persona_is_active(&[], "missing").unwrap_err();
|
||||
|
||||
assert_eq!(err, "persona missing not found");
|
||||
assert_eq!(err, "agent missing not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -254,7 +254,7 @@ fn validate_persona_activation_change_rejects_non_builtins() {
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
"Only built-in personas can be added to or removed from My Agents."
|
||||
"Only built-in agents can be added to or removed from My Agents."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ fn validate_persona_deletion_rejects_builtins() {
|
||||
|
||||
let err = validate_persona_deletion(&persona, false).unwrap_err();
|
||||
|
||||
assert_eq!(err, "Built-in personas cannot be deleted.");
|
||||
assert_eq!(err, "Built-in agents cannot be deleted.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -718,9 +718,9 @@ export function AgentInstanceEditDialog({
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
{/* Avatar is display-only — UpdateManagedAgentInput has no
|
||||
avatarUrl field, so edits can't be persisted yet. Keep the
|
||||
preview disabled until the backend supports avatar updates. */}
|
||||
{/* Avatar is definition-level identity (Wes decision 2026-07-09,
|
||||
"2a"): edit it on the agent profile; instances inherit and
|
||||
re-sync on restart. Read-only here by design. */}
|
||||
<AgentCreationPreview
|
||||
avatarUrl={previewAvatarUrl}
|
||||
disabled
|
||||
|
||||
@@ -64,7 +64,7 @@ export function rankMentionCandidates<T extends MentionCandidateForRanking>(
|
||||
: "";
|
||||
const label =
|
||||
candidate.displayName ??
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "persona");
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent");
|
||||
const groupRank = getMentionCandidateGroupRank(
|
||||
candidate,
|
||||
activePersonaIds,
|
||||
|
||||
@@ -62,7 +62,7 @@ type MentionCandidate = {
|
||||
function mentionCandidateLabel(candidate: MentionCandidate) {
|
||||
return (
|
||||
candidate.displayName ??
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "persona")
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6325,10 +6325,10 @@ async function handleUpdatePersona(args: {
|
||||
(candidate) => candidate.id === args.input.id,
|
||||
);
|
||||
if (!persona) {
|
||||
throw new Error(`Persona ${args.input.id} not found.`);
|
||||
throw new Error(`agent ${args.input.id} not found`);
|
||||
}
|
||||
if (persona.is_builtin) {
|
||||
throw new Error("Built-in personas cannot be edited.");
|
||||
throw new Error("Built-in agents cannot be edited.");
|
||||
}
|
||||
|
||||
persona.display_name = args.input.displayName.trim();
|
||||
@@ -6347,10 +6347,10 @@ async function handleUpdatePersona(args: {
|
||||
async function handleDeletePersona(args: { id: string }): Promise<void> {
|
||||
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
|
||||
if (!persona) {
|
||||
throw new Error(`Persona ${args.id} not found.`);
|
||||
throw new Error(`agent ${args.id} not found`);
|
||||
}
|
||||
if (persona.is_builtin) {
|
||||
throw new Error("Built-in personas cannot be deleted.");
|
||||
throw new Error("Built-in agents cannot be deleted.");
|
||||
}
|
||||
if (mockTeams.some((team) => team.persona_ids.includes(args.id))) {
|
||||
throw new Error(
|
||||
@@ -6374,11 +6374,11 @@ async function handleSetPersonaActive(args: {
|
||||
}): Promise<RawPersona> {
|
||||
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
|
||||
if (!persona) {
|
||||
throw new Error(`Persona ${args.id} not found.`);
|
||||
throw new Error(`agent ${args.id} not found`);
|
||||
}
|
||||
if (!persona.is_builtin) {
|
||||
throw new Error(
|
||||
"Only built-in personas can be added to or removed from My Agents.",
|
||||
"Only built-in agents can be added to or removed from My Agents.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -6406,7 +6406,7 @@ async function handleSetPersonaActive(args: {
|
||||
function ensureMockPersonaIsActive(personaId: string) {
|
||||
const persona = mockPersonas.find((candidate) => candidate.id === personaId);
|
||||
if (!persona) {
|
||||
throw new Error(`persona ${personaId} not found`);
|
||||
throw new Error(`agent ${personaId} not found`);
|
||||
}
|
||||
if (!persona.is_active) {
|
||||
throw new Error(
|
||||
@@ -6596,7 +6596,7 @@ async function handleExportPersonaToJson(args: {
|
||||
}): Promise<boolean> {
|
||||
// In test mode, just verify the persona exists
|
||||
const persona = mockPersonas.find((p) => p.id === args.id);
|
||||
if (!persona) throw new Error(`Persona ${args.id} not found.`);
|
||||
if (!persona) throw new Error(`agent ${args.id} not found`);
|
||||
return true; // Simulate successful save
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user