diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index 08d52a75b5..1c9eb9f5c8 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -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()); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index c3fb76d0dd..c517aaf58e 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -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::(); - 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 { + 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 `::` 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(), diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index ccaf4f31f1..38bdfd44e0 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -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 { diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 0c10d6e765..be0231ce0e 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -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] diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f2fe3de689..7ac337badc 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -718,9 +718,9 @@ export function AgentInstanceEditDialog({ } >
- {/* 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. */} ( : ""; const label = candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "persona"); + (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent"); const groupRank = getMentionCandidateGroupRank( candidate, activePersonaIds, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index ad883df822..2b78cf56aa 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -61,7 +61,7 @@ type MentionCandidate = { function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "persona") + (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent") ); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cf2cc35dc3..6f34e5cf12 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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 { 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 { 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 { // 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 }