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:
Wes
2026-07-09 15:24:06 -06:00
committed by GitHub
parent b41cf3ffc6
commit f0e65589a9
8 changed files with 119 additions and 39 deletions

View File

@@ -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());
}

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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

View File

@@ -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,

View File

@@ -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")
);
}

View File

@@ -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
}