Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 68 additions & 7 deletions desktop/src-tauri/src/commands/personas/inbound_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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();
Comment thread
wpfleger96 marked this conversation as resolved.
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());
}
41 changes: 31 additions & 10 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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()))
})
Expand Down Expand Up @@ -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)?
Expand Down Expand Up @@ -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(),
Expand Down
10 changes: 4 additions & 6 deletions desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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());
Comment thread
wpfleger96 marked this conversation as resolved.
}

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
));
}
Expand All @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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."
);
}

Expand Down Expand Up @@ -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]
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/lib/mentionRanking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}

Expand Down
16 changes: 8 additions & 8 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand All @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}

Expand Down
Loading