diff --git a/Cargo.lock b/Cargo.lock index d14006fb1..f44b6fcd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3920,6 +3920,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sprout-core", "sprout-persona", "sprout-sdk", "thiserror 2.0.18", @@ -3934,12 +3935,14 @@ version = "0.1.0" dependencies = [ "chrono", "hex", + "hmac 0.13.0", "nostr", "percent-encoding", "rand 0.10.1", "schemars", "serde", "serde_json", + "sha2 0.11.0", "subtle", "thiserror 2.0.18", "url", diff --git a/TESTING.md b/TESTING.md index 95ba49d40..ca20cd3b5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -225,6 +225,7 @@ sprout channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role export SPROUT_PRIVATE_KEY="$AGENT_SK" export SPROUT_RELAY_URL=ws://localhost:3000 # match step 3 (e.g. ws://localhost:3030 if overridden) export SPROUT_ACP_RESPOND_TO=anyone # default is owner-only; opens the gate for testing +export SPROUT_ACP_MEMORY=true # opt in to NIP-AE core-memory prompt injection export SPROUT_ACP_MCP_COMMAND="$PWD/target/release/sprout-mcp-server" # explicit path beats $PATH export GOOSE_MODE=auto # must be 'auto' or goose hangs on prompts diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index f7cf49232..fe2d2fd80 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -328,6 +328,30 @@ pub struct CliArgs { #[arg(long, env = "SPROUT_ACP_NO_TYPING")] pub no_typing: bool, + /// Enable NIP-AE agent core memory injection. + /// + /// Memory injection is off by default for now. When enabled, the harness + /// fetches the agent's per-session core engram and renders it as an + /// `[Agent Memory — core]` prompt section (or renders the onboarding nudge + /// when the relay confirms no core engram exists). The `sprout mem` CLI + /// and the relay's acceptance of kind:30174 engrams are unaffected — this + /// flag controls prompt-time injection in the ACP harness only. + #[arg(long, env = "SPROUT_ACP_MEMORY", conflicts_with = "no_memory")] + pub memory: bool, + + /// Disable NIP-AE agent core memory injection. + /// + /// Deprecated compatibility alias for the previous default-on behavior. + /// The flag/env var is still accepted, but memory injection is already off + /// unless `--memory` / `SPROUT_ACP_MEMORY=true` is provided. + #[arg( + long, + env = "SPROUT_ACP_NO_MEMORY", + conflicts_with = "memory", + hide = true + )] + pub no_memory: bool, + /// Desired LLM model ID. Applied to every new ACP session after creation. /// Use `sprout-acp models` to discover available model IDs. #[arg(long, env = "SPROUT_ACP_MODEL")] @@ -416,6 +440,11 @@ pub struct Config { pub max_turns_per_session: u32, pub presence_enabled: bool, pub typing_enabled: bool, + /// Whether NIP-AE agent core memory injection is enabled. When false, + /// the harness skips the per-session core engram fetch and renders no + /// `[Agent Memory — core]` section. Mirrors the `--memory` / + /// `SPROUT_ACP_MEMORY` opt-in. + pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. pub model: Option, /// Permission mode to apply after session creation. `Default` = skip. @@ -761,6 +790,7 @@ impl Config { max_turns_per_session: args.max_turns_per_session, presence_enabled: !args.no_presence, typing_enabled: !args.no_typing, + memory_enabled: args.memory && !args.no_memory, model, permission_mode: args.permission_mode, respond_to: args.respond_to, @@ -782,7 +812,7 @@ impl Config { other => format!("respond_to={other}"), }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} model={} permission_mode={} {}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, @@ -800,6 +830,7 @@ impl Config { self.max_turns_per_session, self.presence_enabled, self.typing_enabled, + self.memory_enabled, self.model.as_deref().unwrap_or("(agent default)"), self.permission_mode, respond_to_detail, @@ -1122,6 +1153,7 @@ mod tests { max_turns_per_session: 0, presence_enabled: true, typing_enabled: true, + memory_enabled: false, model: None, permission_mode: PermissionMode::BypassPermissions, respond_to: RespondTo::Anyone, @@ -1705,6 +1737,38 @@ channels = "ALL" ); } + // ── memory toggle ─────────────────────────────────────────────────────── + + #[test] + fn test_memory_enabled_default_false() { + let config = test_config(SubscribeMode::Mentions); + assert!( + !config.memory_enabled, + "memory_enabled should default to false" + ); + } + + #[test] + fn test_summary_includes_memory_disabled() { + let config = test_config(SubscribeMode::Mentions); + let s = config.summary(); + assert!( + s.contains("memory=false"), + "summary should include memory=false by default, got: {s}" + ); + } + + #[test] + fn test_summary_reflects_memory_enabled() { + let mut config = test_config(SubscribeMode::Mentions); + config.memory_enabled = true; + let s = config.summary(); + assert!( + s.contains("memory=true"), + "summary should include memory=true when enabled, got: {s}" + ); + } + // ── permission mode ───────────────────────────────────────────────────── #[test] diff --git a/crates/sprout-acp/src/engram_fetch.rs b/crates/sprout-acp/src/engram_fetch.rs new file mode 100644 index 000000000..0a2c45cc8 --- /dev/null +++ b/crates/sprout-acp/src/engram_fetch.rs @@ -0,0 +1,248 @@ +//! Fetch the agent's NIP-AE `core` engram at session creation and render it +//! into a prompt section. +//! +//! Scope per Tyler's spec: +//! - Fire one synchronous query for the core head when a *new* session is born. +//! - If a body is found, emit `[Agent Memory — core]\n`. +//! - If no body is found, emit an onboarding nudge so the agent learns how +//! to set its own core. +//! - On any *error* (transport, parse), log and emit nothing. We must not +//! mistake a relay outage for "no core" — that would invite the agent to +//! overwrite real, just-unreachable memory with a fresh profile. +//! - Either way, session creation is never blocked. + +use nostr::{Event, Keys, PublicKey}; +use sprout_core::engram::{conversation_key, d_tag, select_head, validate_and_decrypt, Body}; +use sprout_core::kind::KIND_AGENT_ENGRAM; + +use crate::relay::RestClient; + +/// Section header rendered into the prompt. +const SECTION_LABEL: &str = "Agent Memory — core"; + +/// Onboarding nudge for new agents with no core yet. +/// +/// Wording is from Tyler's brief: "No core memory found. Use `sprout mem` +/// to create a core memory. Ask your user about yourself." +pub const ONBOARDING_NUDGE: &str = "No core memory found. \ +Use `sprout mem set core \"…\"` to create one (it will hold your identity, \ +rules, and goals across sessions). Ask your user about yourself."; + +/// Build the rendered prompt section for the agent's core. +/// +/// Returns: +/// - `Some(profile_section)` when a valid core exists, +/// - `Some(nudge_section)` when the relay confirmed absence, +/// - `None` when the fetch failed (transport, parse, decrypt) — the caller +/// should inject no section in that case so the agent doesn't conclude +/// memory is empty. +pub async fn build_core_section( + rest: &RestClient, + agent_keys: &Keys, + owner: &PublicKey, +) -> Option { + match fetch_core_body(rest, agent_keys, owner).await { + Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")), + Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")), + Err(reason) => { + tracing::warn!( + target: "engram::core", + "core fetch failed: {reason} — emitting no section to avoid \ + confusing a relay outage with an absent core" + ); + None + } + } +} + +/// Query the relay for the core head and decode it. Returns: +/// - `Ok(Some(profile))` if a valid core body was found, +/// - `Ok(None)` only if the relay confirmed absence (empty result set), +/// - `Err(reason)` if the relay returned candidates we could not parse, +/// verify, or decrypt — those are NOT treated as absence (would let an +/// unreadable but real core be silently overwritten by the onboarding nudge), +/// - `Err` for transport / parse errors. +async fn fetch_core_body( + rest: &RestClient, + agent_keys: &Keys, + owner: &PublicKey, +) -> Result, String> { + let k_c = conversation_key(agent_keys.secret_key(), owner); + let d = d_tag(&k_c, sprout_core::engram::CORE_SLUG); + + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .author(agent_keys.public_key()) + .custom_tag(nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), [d]) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::P), + [owner.to_hex()], + ) + .limit(16); + + let value = rest + .query(&[filter]) + .await + .map_err(|e| format!("relay query failed: {e}"))?; + let arr = value + .as_array() + .ok_or_else(|| "relay query returned non-array".to_string())?; + decode_core_body(arr, agent_keys, owner) +} + +/// Pure decoder: given the relay's JSON array, decide whether we have a +/// readable core, confirmed absence, or an ambiguous unreadable-state. +/// +/// - Empty array → `Ok(None)` (confirmed absence; caller renders the nudge). +/// - At least one event decrypts → use the winning head's body. +/// * Body::Core → `Ok(Some(profile))` +/// * Body::Tombstone or unexpected shape → `Ok(None)` (treat as absent). +/// - Non-empty array but nothing decrypts → `Err` (fail closed; caller +/// emits no section, so the agent does not assume memory is empty and +/// try to overwrite a real-but-unreadable core). +fn decode_core_body( + arr: &[serde_json::Value], + agent_keys: &Keys, + owner: &PublicKey, +) -> Result, String> { + if arr.is_empty() { + return Ok(None); + } + let mut valid_with_body: Vec<(Event, Body)> = Vec::with_capacity(arr.len()); + let mut candidates_seen = 0usize; + let mut last_decrypt_err: Option = None; + for ev_json in arr { + let event: Event = match serde_json::from_value(ev_json.clone()) { + Ok(e) => e, + Err(_) => continue, + }; + if event.verify().is_err() { + continue; + } + candidates_seen += 1; + match validate_and_decrypt( + &event, + &agent_keys.public_key(), + owner, + agent_keys.secret_key(), + owner, + ) { + Ok(body) => valid_with_body.push((event, body)), + Err(e) => { + last_decrypt_err = Some(e.to_string()); + continue; + } + } + } + if valid_with_body.is_empty() { + if candidates_seen > 0 { + return Err(format!( + "{candidates_seen} core candidate(s) returned but none decryptable (last error: {})", + last_decrypt_err.as_deref().unwrap_or("unknown") + )); + } + return Err( + "relay returned core candidate(s) that could not be parsed or verified".to_string(), + ); + } + let events: Vec = valid_with_body.iter().map(|(e, _)| e.clone()).collect(); + // `select_head` returns `None` only on an empty iterator, which we + // ruled out above. + let Some(head) = select_head(events) else { + return Ok(None); + }; + let head_id = head.id; + let body = valid_with_body + .into_iter() + .find(|(e, _)| e.id == head_id) + .map(|(_, b)| b); + match body { + Some(Body::Core { profile }) => Ok(Some(profile)), + // A tombstone or unexpectedly-shaped head means "no usable core." + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use sprout_core::engram::{build_event, Body}; + + /// Empty array → confirmed absence → Ok(None), so the caller emits the + /// onboarding nudge. This is the only path that maps to "no core." + #[test] + fn decode_empty_array_is_confirmed_absence() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let out = decode_core_body(&[], &agent, &owner.public_key()).unwrap(); + assert_eq!(out, None); + } + + /// Happy path: a real, decryptable core event yields the profile. + #[test] + fn decode_valid_core_returns_profile() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let body = Body::Core { + profile: "I am Sami.".to_string(), + }; + let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap(); + let arr = vec![serde_json::to_value(&ev).unwrap()]; + let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap(); + assert_eq!(out.as_deref(), Some("I am Sami.")); + } + + /// Regression: when the relay returns a kind:30174 event addressed to + /// this agent that we cannot decrypt (here: encrypted to a *different* + /// owner's key, so the MAC fails for this agent↔owner pair), we MUST + /// return Err and NOT Ok(None). Returning Ok(None) would cause the + /// harness to emit the onboarding nudge, inviting the agent to overwrite + /// a real-but-unreadable core. + #[test] + fn decode_undecryptable_candidate_is_err_not_absent() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let wrong_owner = Keys::generate(); + // Build an engram encrypted to wrong_owner (not owner). It will pass + // sig verification but fail MAC/decrypt for the agent↔owner pair. + let body = Body::Core { + profile: "secret".to_string(), + }; + let ev = build_event(&agent, &wrong_owner.public_key(), &body, 1_700_000_000).unwrap(); + let arr = vec![serde_json::to_value(&ev).unwrap()]; + let result = decode_core_body(&arr, &agent, &owner.public_key()); + assert!(result.is_err(), "expected Err, got: {result:?}"); + let msg = result.unwrap_err(); + assert!(msg.contains("decryptable"), "got: {msg}"); + } + + /// An unexpectedly-shaped head (here: a Memory body in what was supposed + /// to be the core slot) is a legitimate, decryptable "no usable core" — + /// Ok(None). Real `rm core` is refused at the CLI, so this is a defensive + /// branch for malformed data on the wire. + #[test] + fn decode_non_core_body_is_absent() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let body = Body::Memory { + slug: "mem/x".to_string(), + value: None, + }; + let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap(); + let arr = vec![serde_json::to_value(&ev).unwrap()]; + let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap(); + assert_eq!(out, None); + } + + /// Non-empty array with only garbage entries (not even parseable as + /// events) is also treated as a fetch error, not absence. + #[test] + fn decode_unparseable_candidates_is_err() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let arr = vec![json!({"not": "an event"}), json!("garbage")]; + let result = decode_core_body(&arr, &agent, &owner.public_key()); + assert!(result.is_err(), "expected Err, got: {result:?}"); + } +} diff --git a/crates/sprout-acp/src/lib.rs b/crates/sprout-acp/src/lib.rs index e6e173bec..c9878a997 100644 --- a/crates/sprout-acp/src/lib.rs +++ b/crates/sprout-acp/src/lib.rs @@ -2,6 +2,7 @@ mod acp; mod config; +mod engram_fetch; mod filter; mod observer; mod pool; @@ -967,7 +968,7 @@ async fn tokio_main() -> Result<()> { _ => {} // anyone/nobody don't depend on owner } } - let owner_cache = OwnerCache::new(startup_owner); + let owner_cache = OwnerCache::new(startup_owner.clone()); let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; @@ -1084,8 +1085,20 @@ async fn tokio_main() -> Result<()> { context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, + agent_keys: config.keys.clone(), + agent_owner_pubkey: startup_owner + .as_deref() + .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), + memory_enabled: config.memory_enabled, }); + if !config.memory_enabled { + tracing::info!( + target: "engram::core", + "NIP-AE core memory injection disabled by default (enable with --memory / SPROUT_ACP_MEMORY)" + ); + } + // ── Step 6: Heartbeat timer ─────────────────────────────────────────────── let mut heartbeat = if config.heartbeat_interval_secs > 0 { let interval = Duration::from_secs(config.heartbeat_interval_secs); @@ -2748,6 +2761,7 @@ mod build_mcp_servers_tests { max_turns_per_session: 0, presence_enabled: true, typing_enabled: true, + memory_enabled: false, model: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index b489f394d..80a6936ea 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -82,6 +82,9 @@ pub struct SessionState { pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, + /// channel_id → rendered NIP-AE core prompt section, populated once at + /// session creation per Tyler's spec (no mid-session refresh). + pub core_sections: HashMap, } impl SessionState { @@ -102,6 +105,7 @@ impl SessionState { /// Returns `true` if the channel had an active session. pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { self.turn_counts.remove(channel_id); + self.core_sections.remove(channel_id); self.sessions.remove(channel_id).is_some() } @@ -111,6 +115,7 @@ impl SessionState { self.turn_counts.clear(); self.heartbeat_session = None; self.heartbeat_turn_count = 0; + self.core_sections.clear(); } } @@ -198,6 +203,18 @@ pub struct PromptContext { pub max_turns_per_session: u32, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, + /// Agent identity — used to derive the NIP-AE conversation key at + /// session creation for core injection. + pub agent_keys: nostr::Keys, + /// Owner pubkey (hex), if resolved at startup. When unset, NIP-AE core + /// injection is skipped entirely (no owner = no `(agent, owner)` pair). + pub agent_owner_pubkey: Option, + /// Whether NIP-AE agent core memory injection is enabled. When false, + /// the per-session core engram fetch is skipped and `core_sections` + /// remains empty for every channel, so `format_prompt` renders no + /// `[Agent Memory — core]` section. Driven by `--memory` / + /// `SPROUT_ACP_MEMORY`. + pub memory_enabled: bool, } // ── AgentPool impl ──────────────────────────────────────────────────────────── @@ -737,6 +754,67 @@ pub async fn run_prompt_task( }), ); + // ── NIP-AE: fetch core engram on new channel sessions ──────────────── + // + // Fire one synchronous fetch + decrypt + render right after the session + // is born; cache the rendered section in `state.core_sections` keyed by + // channel id. Subsequent turns in the same session reuse the cached + // section. `format_prompt` reads it from the per-channel cache. + // + // Failure modes (all fail open — no crash, no block): + // * no owner configured → skip (no NIP-AE namespace exists) + // * confirmed absence → cache the onboarding nudge so the agent + // learns how to bootstrap itself. + // * transport / decrypt / parse error → inject nothing. We never + // mistake "relay slow or broken" for "no core" — that would invite + // the agent to overwrite real, just-unreachable memory. + // * fetch exceeds CORE_FETCH_TIMEOUT → inject nothing, same reason. + // + // Per Tyler's locked spec: NO mid-session refreshes. Re-fetch only + // happens when a session is invalidated and recreated (see + // `SessionState::invalidate_channel`). + // + // Operator opt-in: `--memory` / `SPROUT_ACP_MEMORY` enables the NIP-AE + // injection path. By default we skip the fetch outright and leave + // `state.core_sections` empty, so `format_prompt` renders no core + // section. The `sprout mem` CLI and the relay's acceptance of + // kind:30174 engrams are unaffected. + if is_new_session && ctx.memory_enabled { + if let (PromptSource::Channel(cid), Some(owner_pk)) = + (&source, ctx.agent_owner_pubkey.as_ref()) + { + // Bounded — we'd rather start the session with no core hint + // than block prompt formatting on a stalled relay. + const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + let fetch = crate::engram_fetch::build_core_section( + &ctx.rest_client, + &ctx.agent_keys, + owner_pk, + ); + let section = match tokio::time::timeout(CORE_FETCH_TIMEOUT, fetch).await { + Ok(s) => s, + Err(_) => { + tracing::warn!( + target: "engram::core", + channel = %cid, + timeout_ms = CORE_FETCH_TIMEOUT.as_millis() as u64, + "core fetch timed out — emitting no section" + ); + None + } + }; + if let Some(rendered) = section { + tracing::info!( + target: "engram::core", + channel = %cid, + section_len = rendered.len(), + "injected NIP-AE core section" + ); + agent.state.core_sections.insert(*cid, rendered); + } + } + } + // ── Send initial_message on new channel sessions ────────────────────── if is_new_session { @@ -871,9 +949,11 @@ pub async fn run_prompt_task( let profile_lookup = fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; + let agent_core_section = agent.state.core_sections.get(&b.channel_id).cloned(); crate::queue::format_prompt( b, ctx.system_prompt.as_deref(), + agent_core_section.as_deref(), channel_info.as_ref(), conversation_context.as_ref(), profile_lookup.as_ref(), diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index a4077fce7..aca7f432b 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -952,6 +952,7 @@ fn format_conversation_context( pub fn format_prompt( batch: &FlushBatch, system_prompt: Option<&str>, + agent_core: Option<&str>, channel_info: Option<&PromptChannelInfo>, conversation_context: Option<&ConversationContext>, profile_lookup: Option<&PromptProfileLookup>, @@ -979,6 +980,11 @@ pub fn format_prompt( sections.push(format!("[System]\n{sp}")); } + // 1b. NIP-AE agent core memory (rendered by `engram_fetch::build_core_section`). + if let Some(core) = agent_core { + sections.push(core.to_string()); + } + // 2. Context hints (with reply instruction for thread replies). let triggering_event_id = if thread_tags.root_event_id.is_some() { Some(last_event.event.id.to_hex()) @@ -1298,7 +1304,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); // Should contain [Context] section before the event. assert!(prompt.contains("[Context]")); @@ -1394,7 +1400,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!(prompt.contains("[Context]")); assert!(prompt.contains("[Sprout events — 3 events]")); @@ -1423,10 +1429,58 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, Some("You are a triage bot."), None, None, None); + let prompt = format_prompt( + &batch, + Some("You are a triage bot."), + None, + None, + None, + None, + ); assert!(prompt.starts_with("[System]\nYou are a triage bot.\n\n[Context]")); } + // ── Test 11b: agent_core section is injected after [System] ────────────── + + #[test] + fn test_format_prompt_with_agent_core() { + let ch = Uuid::new_v4(); + let event = make_event("hi"); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + }; + let core = "[Agent Memory — core]\nbe helpful"; + let prompt = format_prompt(&batch, Some("sys"), Some(core), None, None, None); + assert!( + prompt.contains("[System]\nsys\n\n[Agent Memory — core]\nbe helpful"), + "expected core block after [System]; got: {prompt}" + ); + } + + #[test] + fn test_format_prompt_without_system_prompts_core_first() { + let ch = Uuid::new_v4(); + let event = make_event("hi"); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + }; + let core = "[Agent Memory — core]\nbe helpful"; + let prompt = format_prompt(&batch, None, Some(core), None, None, None); + assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]")); + } + // ── Test 12: drop mode discards in-flight channel events ───────────────── #[test] @@ -1903,7 +1957,7 @@ mod tests { channel_type: "stream".into(), }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); assert!(prompt.contains("engineering (#")); assert!(prompt.contains("Scope: channel")); } @@ -1926,7 +1980,7 @@ mod tests { channel_type: "dm".into(), }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); assert!(prompt.contains("Scope: dm")); } @@ -1952,7 +2006,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!(prompt.contains("Scope: thread")); assert!(prompt.contains("Thread root: root123")); } @@ -1995,7 +2049,7 @@ mod tests { truncated: true, }; - let prompt = format_prompt(&batch, None, None, Some(&ctx), None); + let prompt = format_prompt(&batch, None, None, None, Some(&ctx), None); assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]")); assert!(prompt.contains("Let's refactor auth")); assert!(prompt.contains("Thread context included below")); @@ -2028,7 +2082,7 @@ mod tests { truncated: false, }; - let prompt = format_prompt(&batch, None, Some(&ci), Some(&ctx), None); + let prompt = format_prompt(&batch, None, None, Some(&ci), Some(&ctx), None); assert!(prompt.contains("Scope: dm")); assert!(prompt.contains("[Conversation Context (1 of 1 messages)]")); assert!(prompt.contains("Can you deploy?")); @@ -2080,7 +2134,7 @@ mod tests { ), ]); - let prompt = format_prompt(&batch, None, None, Some(&ctx), Some(&profiles)); + let prompt = format_prompt(&batch, None, None, None, Some(&ctx), Some(&profiles)); assert!(prompt.contains("From: Wes (npub:")); assert!(prompt.contains( @@ -2175,7 +2229,7 @@ mod tests { truncated: false, }; - let prompt = format_prompt(&batch, None, Some(&ci), Some(&ctx), None); + let prompt = format_prompt(&batch, None, None, Some(&ci), Some(&ctx), None); // Scope should be "dm", not "thread". assert!( prompt.contains("Scope: dm"), @@ -2214,7 +2268,7 @@ mod tests { }; // No context fetched — hints only. - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); assert!(prompt.contains("Scope: dm")); assert!( prompt.contains("get_channel_history()"), @@ -2241,7 +2295,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("Event ID: {event_id}")), "prompt should contain the event ID" @@ -2264,7 +2318,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("From: {npub} (hex: {hex})")), "prompt should contain both npub and hex" @@ -2286,7 +2340,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains("Tags:"), "tags should always be included, even for stream messages" @@ -2610,7 +2664,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), "channel thread reply should include reply instruction with triggering event ID" @@ -2644,7 +2698,7 @@ mod tests { channel_type: "dm".into(), }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), "DM thread reply should include reply instruction" @@ -2665,7 +2719,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( !prompt.contains("parent_event_id"), "top-level message should NOT include reply instruction" @@ -2690,7 +2744,7 @@ mod tests { channel_type: "dm".into(), }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, None, Some(&ci), None, None); assert!( !prompt.contains("parent_event_id"), "DM non-reply should NOT include reply instruction" @@ -2720,7 +2774,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); // The instruction should use the triggering event's own ID — not root or parent. assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), @@ -2763,7 +2817,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("parent_event_id=\"{threaded_id}\"")), "batched prompt should use last (threaded) event's ID" @@ -2796,7 +2850,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( !prompt.contains("parent_event_id"), "batched prompt where last event is top-level should NOT include reply instruction" diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml index 314930521..a0a488071 100644 --- a/crates/sprout-cli/Cargo.toml +++ b/crates/sprout-cli/Cargo.toml @@ -40,6 +40,7 @@ uuid = { workspace = true } # Typed event builders for all write operations sprout-sdk = { workspace = true } +sprout-core = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index 9789e9be2..4b4bf4cc4 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -161,6 +161,17 @@ impl SproutClient { &self.relay_url } + /// Return the owner pubkey carried by the NIP-OA auth tag, if any. + /// + /// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the + /// owner pubkey lives at index 1. + pub fn auth_tag_owner_hex(&self) -> Option { + self.auth_tag + .as_ref() + .map(|t| t.as_slice()) + .and_then(|slice| slice.get(1).cloned()) + } + /// Sign an event builder, injecting the NIP-OA auth tag if configured. /// /// All event creation should go through this method to ensure consistent diff --git a/crates/sprout-cli/src/commands/mem.rs b/crates/sprout-cli/src/commands/mem.rs new file mode 100644 index 000000000..798ce7d07 --- /dev/null +++ b/crates/sprout-cli/src/commands/mem.rs @@ -0,0 +1,364 @@ +//! `sprout mem` — agent-side engram management (NIP-AE). +//! +//! Subcommands: +//! - `sprout mem ls` — list non-tombstoned memories +//! - `sprout mem get ` — print the value to stdout +//! - `sprout mem set ` — write a value (use `-` for stdin) +//! - `sprout mem rm ` — publish a tombstone +//! +//! The caller's `SPROUT_PRIVATE_KEY` is the agent's nsec. The agent's owner +//! pubkey is resolved from `SPROUT_AUTH_TAG` (NIP-OA attestation) or the +//! `--owner` flag. Every record is encrypted under the agent↔owner NIP-44 +//! conversation key; both parties can decrypt. + +use std::io::Read; +use std::time::SystemTime; + +use nostr::PublicKey; +use sprout_core::engram::{ + self, conversation_key, d_tag, normalize_slug, select_head, validate_and_decrypt, Body, Listing, +}; +use sprout_core::kind::KIND_AGENT_ENGRAM; + +use crate::client::SproutClient; +use crate::error::CliError; + +/// Resolve the agent's owner pubkey: explicit `--owner` flag wins, otherwise +/// fall back to the NIP-OA `auth_tag` (which carries owner pubkey in slot 1). +fn resolve_owner(client: &SproutClient, owner_flag: Option<&str>) -> Result { + if let Some(s) = owner_flag { + return PublicKey::from_hex(s) + .map_err(|e| CliError::Usage(format!("--owner must be a 64-hex pubkey: {e}"))); + } + let tag = client.auth_tag_owner_hex().ok_or_else(|| { + CliError::Usage( + "owner pubkey required (set SPROUT_AUTH_TAG with a NIP-OA attestation or pass --owner)" + .into(), + ) + })?; + PublicKey::from_hex(&tag) + .map_err(|e| CliError::Other(format!("auth_tag owner pubkey is not valid hex: {e}"))) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Submit a signed engram event and confirm the relay treated it as +/// authoritative. The relay returns `{accepted, message}` where the +/// `message` field starts with `"duplicate:"` when the write was rejected +/// as already-superseded by a later head (NIP-33 LWW). In that case we +/// surface a `Conflict` so callers don't lie about success. +async fn submit_engram(client: &SproutClient, event: nostr::Event) -> Result<(), CliError> { + let raw = client.submit_event(event).await?; + let parsed: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?; + let accepted = parsed + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let message = parsed.get("message").and_then(|v| v.as_str()).unwrap_or(""); + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + if message.starts_with("duplicate:") || message == "duplicate" { + return Err(CliError::Conflict( + "relay reported event as duplicate / dominated by a newer head".into(), + )); + } + Ok(()) +} + +/// Parse a relay-response JSON array of events. +fn parse_events(json: &str) -> Result, CliError> { + let value: serde_json::Value = serde_json::from_str(json) + .map_err(|e| CliError::Other(format!("relay returned invalid JSON: {e}")))?; + let arr = value + .as_array() + .ok_or_else(|| CliError::Other("relay response is not an array".into()))?; + // Per NIP-AE head selection: discard events that fail any validation + // step and pick the head from the survivors. A single garbled response + // entry must not deny-of-service the whole listing. + let mut out = Vec::with_capacity(arr.len()); + for ev in arr { + // Skip any event that fails to deserialize. Downstream validation + // (signature, decrypt, slug↔d) will discard further bad apples; we + // never want a single corrupt record to fail `mem ls`/`mem get`. + if let Ok(event) = serde_json::from_value::(ev.clone()) { + out.push(event); + } + } + Ok(out) +} + +/// Fetch the head event for `slug`, returning `(Option, Option)`. +async fn fetch_head( + client: &SproutClient, + owner: &PublicKey, + slug: &str, +) -> Result<(Option, Option), CliError> { + let agent = client.keys(); + let k_c = conversation_key(agent.secret_key(), owner); + let d = d_tag(&k_c, slug); + + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_ENGRAM], + "authors": [agent.public_key().to_hex()], + "#d": [d], + "#p": [owner.to_hex()], + "limit": 16, + }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw)?; + + let mut valid_with_body: Vec<(nostr::Event, Body)> = Vec::new(); + for ev in events { + // Signature is validated by NIP-01 / NIP-44 — `nostr::Event::verify` is + // the conservative belt-and-suspenders check before decrypting. + if ev.verify().is_err() { + continue; + } + match validate_and_decrypt(&ev, &agent.public_key(), owner, agent.secret_key(), owner) { + Ok(body) => valid_with_body.push((ev, body)), + Err(_) => continue, + } + } + if valid_with_body.is_empty() { + return Ok((None, None)); + } + let events: Vec = valid_with_body.iter().map(|(e, _)| e.clone()).collect(); + // `select_head` returns `None` only on an empty iterator; we guarded + // that above, so the head is always present. + let Some(head) = select_head(events) else { + return Ok((None, None)); + }; + let body = valid_with_body + .into_iter() + .find(|(e, _)| e.id == head.id) + .map(|(_, b)| b); + Ok((Some(head), body)) +} + +/// `sprout mem ls` — list non-tombstoned memory entries. +pub async fn cmd_ls( + client: &SproutClient, + owner_flag: Option<&str>, + json: bool, +) -> Result<(), CliError> { + let owner = resolve_owner(client, owner_flag)?; + let agent = client.keys(); + + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_ENGRAM], + "authors": [agent.public_key().to_hex()], + "#p": [owner.to_hex()], + "limit": 5000, + }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw)?; + + // Validate + decrypt + group by d tag. + use std::collections::HashMap; + let mut groups: HashMap> = HashMap::new(); + for ev in events { + if ev.verify().is_err() { + continue; + } + let Some(d_value) = ev + .tags + .iter() + .find(|t| t.kind().to_string() == "d") + .and_then(|t| t.content()) + .map(|s| s.to_string()) + else { + continue; + }; + let body = match validate_and_decrypt( + &ev, + &agent.public_key(), + &owner, + agent.secret_key(), + &owner, + ) { + Ok(b) => b, + Err(_) => continue, + }; + groups.entry(d_value).or_default().push((ev, body)); + } + + let mut listings: Vec = Vec::new(); + for (_d, members) in groups { + let events: Vec = members.iter().map(|(e, _)| e.clone()).collect(); + let Some(head) = select_head(events) else { + continue; + }; + // `select_head` returns one of the events it received, so the head + // is always present in `members`. If something pathological breaks + // that invariant, skip the group rather than panic. + let Some((_, body)) = members.into_iter().find(|(e, _)| e.id == head.id) else { + continue; + }; + // Drop tombstones and the core entry (per spec: listing excludes core). + match &body { + Body::Core { .. } => continue, + Body::Memory { value: None, .. } => continue, + Body::Memory { slug, .. } => { + listings.push(Listing { + slug: slug.clone(), + event_id: head.id.to_hex(), + created_at: head.created_at.as_u64(), + }); + } + } + } + listings.sort_by(|a, b| a.slug.cmp(&b.slug)); + + if json { + println!("{}", serde_json::to_string(&listings).unwrap_or_default()); + } else if listings.is_empty() { + eprintln!("(no memories)"); + } else { + for l in &listings { + println!("{}\t{}\t{}", l.slug, l.created_at, l.event_id); + } + } + Ok(()) +} + +/// `sprout mem get ` — print value (memory) or profile (core) to stdout. +/// +/// Exit codes: 0 on found, 1 on absent or tombstoned. +pub async fn cmd_get( + client: &SproutClient, + raw_slug: &str, + owner_flag: Option<&str>, +) -> Result<(), CliError> { + let slug = + normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + let owner = resolve_owner(client, owner_flag)?; + let (_head, body) = fetch_head(client, &owner, &slug).await?; + use std::io::Write; + match body { + None => Err(CliError::NotFound(format!("not found: {slug}"))), + Some(Body::Memory { value: None, .. }) => { + Err(CliError::NotFound(format!("tombstoned: {slug}"))) + } + Some(Body::Memory { value: Some(v), .. }) => { + // Raw stdout, no trailing newline — round-trips with `sprout mem set foo -`. + std::io::stdout() + .write_all(v.as_bytes()) + .map_err(|e| CliError::Other(e.to_string())) + } + Some(Body::Core { profile }) => std::io::stdout() + .write_all(profile.as_bytes()) + .map_err(|e| CliError::Other(e.to_string())), + } +} + +/// `sprout mem set ` — write a value or core profile. +/// +/// Pass `-` to read the value from stdin. +pub async fn cmd_set( + client: &SproutClient, + raw_slug: &str, + raw_value: &str, + owner_flag: Option<&str>, +) -> Result<(), CliError> { + let slug = + normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + let value = if raw_value == "-" { + // Bound the stdin read so a runaway producer can't OOM us. We allow + // one extra byte over the NIP-44 plaintext cap so the build step can + // surface an exact `BodyTooLarge` if the cap is breached. + let limit = engram::NIP44_PLAINTEXT_MAX + 1; + let mut buf = String::new(); + std::io::stdin() + .take(limit as u64) + .read_to_string(&mut buf) + .map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?; + if buf.len() > engram::NIP44_PLAINTEXT_MAX { + return Err(CliError::Usage(format!( + "stdin value exceeds {}-byte NIP-44 plaintext limit", + engram::NIP44_PLAINTEXT_MAX + ))); + } + buf + } else { + raw_value.to_string() + }; + let owner = resolve_owner(client, owner_flag)?; + let body = if slug == engram::CORE_SLUG { + Body::Core { profile: value } + } else { + Body::Memory { + slug: slug.clone(), + value: Some(value), + } + }; + let (head, _) = fetch_head(client, &owner, &slug).await?; + let prior_created_at = head.map(|e| e.created_at.as_u64()); + let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); + + let agent = client.keys(); + let event = engram::build_event(agent, &owner, &body, created_at) + .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let id = event.id.to_hex(); + submit_engram(client, event).await?; + eprintln!("wrote {slug} (event {id}, created_at {created_at})"); + Ok(()) +} + +/// `sprout mem rm ` — publish a tombstone (`value: null`). +/// +/// `rm core` writes a tombstone-shaped body, but a core tombstone has no +/// well-defined semantics in NIP-AE (the spec only defines tombstones for +/// memory entries). We refuse it and tell the operator to overwrite `core` +/// with an empty profile instead. +pub async fn cmd_rm( + client: &SproutClient, + raw_slug: &str, + owner_flag: Option<&str>, +) -> Result<(), CliError> { + let slug = + normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + if slug == engram::CORE_SLUG { + return Err(CliError::Usage( + "core cannot be tombstoned; overwrite it with `sprout mem set core ''` instead".into(), + )); + } + let owner = resolve_owner(client, owner_flag)?; + let body = Body::Memory { + slug: slug.clone(), + value: None, + }; + let (head, _) = fetch_head(client, &owner, &slug).await?; + let prior_created_at = head.map(|e| e.created_at.as_u64()); + let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); + + let agent = client.keys(); + let event = engram::build_event(agent, &owner, &body, created_at) + .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let id = event.id.to_hex(); + submit_engram(client, event).await?; + eprintln!("tombstoned {slug} (event {id}, created_at {created_at})"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::MemCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::MemCmd; + match cmd { + MemCmd::Ls { owner, json } => cmd_ls(client, owner.as_deref(), json).await, + MemCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, + MemCmd::Set { slug, value, owner } => { + cmd_set(client, &slug, &value, owner.as_deref()).await + } + MemCmd::Rm { slug, owner } => cmd_rm(client, &slug, owner.as_deref()).await, + } +} diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs index 077f00397..d3b066b6d 100644 --- a/crates/sprout-cli/src/commands/mod.rs +++ b/crates/sprout-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod channels; pub mod dms; pub mod feed; +pub mod mem; pub mod messages; pub mod pack; pub mod reactions; diff --git a/crates/sprout-cli/src/error.rs b/crates/sprout-cli/src/error.rs index 6e99e4bce..465ccfb49 100644 --- a/crates/sprout-cli/src/error.rs +++ b/crates/sprout-cli/src/error.rs @@ -22,13 +22,24 @@ pub enum CliError { #[error("key error: {0}")] Key(String), + /// Relay accepted the event but reported it as superseded by a newer + /// head — used by `sprout mem` set/rm to surface NIP-33 LWW conflicts. + #[error("conflict: {0}")] + Conflict(String), + + /// Requested resource was absent or tombstoned (e.g. `sprout mem get` + /// for a slug with no head). + #[error("{0}")] + NotFound(String), + /// Catch-all for unexpected failures #[error("{0}")] Other(String), } /// Map CliError to process exit code. -/// 0=success (not an error), 1=user, 2=network/relay, 3=auth, 4=other +/// 0=success (not an error), 1=user/not-found, 2=network/relay, 3=auth, +/// 4=other, 5=write conflict (NIP-33 dominated head). pub fn exit_code(e: &CliError) -> i32 { match e { CliError::Usage(_) => 1, @@ -42,6 +53,8 @@ pub fn exit_code(e: &CliError) -> i32 { CliError::Network(_) => 2, CliError::Auth(_) => 3, CliError::Key(_) => 3, + CliError::Conflict(_) => 5, + CliError::NotFound(_) => 1, CliError::Other(_) => 4, } } @@ -61,6 +74,8 @@ pub fn print_error(e: &CliError) { CliError::Network(_) => "network_error", CliError::Auth(_) => "auth_error", CliError::Key(_) => "key_error", + CliError::Conflict(_) => "conflict", + CliError::NotFound(_) => "not_found", CliError::Other(_) => "error", }; let obj = serde_json::json!({ diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index fe16cbdfc..6fa58bb31 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -63,7 +63,7 @@ Configuration (flags override env vars): The 'pack' subcommand runs locally and does not require a relay connection. -Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other +Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other 5=write conflict Errors are JSON on stderr: {\"error\": \"\", \"message\": \"\"}" )] struct Cli { @@ -184,6 +184,9 @@ enum Cmd { /// Upload files to the relay's Blossom store #[command(subcommand)] Upload(UploadCmd), + /// Agent engram management — persistent memory per NIP-AE + #[command(subcommand)] + Mem(MemCmd), /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), @@ -806,6 +809,43 @@ pub enum UploadCmd { }, } +// --------------------------------------------------------------------------- +// Mem subcommands (NIP-AE) +// --------------------------------------------------------------------------- + +/// Subcommands for `sprout mem`. +#[derive(Subcommand)] +pub enum MemCmd { + /// List non-tombstoned memory entries + Ls { + /// Owner pubkey (hex). Overrides SPROUT_AUTH_TAG. + #[arg(long)] + owner: Option, + /// Emit JSON instead of tab-delimited lines. + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Print the value of a slug to stdout (no trailing newline) + Get { + slug: String, + #[arg(long)] + owner: Option, + }, + /// Set a slug's value. Pass `-` to read the value from stdin. + Set { + slug: String, + value: String, + #[arg(long)] + owner: Option, + }, + /// Publish a tombstone for a slug (cannot be used on `core`). + Rm { + slug: String, + #[arg(long)] + owner: Option, + }, +} + // --------------------------------------------------------------------------- // Pack subcommands (local, no relay connection needed) // --------------------------------------------------------------------------- @@ -878,6 +918,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, + Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, Cmd::Pack(_) => unreachable!("handled above"), } } @@ -904,6 +945,7 @@ mod tests { "channels", "dms", "feed", + "mem", "messages", "pack", "reactions", diff --git a/crates/sprout-core/Cargo.toml b/crates/sprout-core/Cargo.toml index 36f3cdea6..5df15aedc 100644 --- a/crates/sprout-core/Cargo.toml +++ b/crates/sprout-core/Cargo.toml @@ -19,6 +19,8 @@ thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } hex = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } schemars = { workspace = true, optional = true } rand = { workspace = true } subtle = { workspace = true } diff --git a/crates/sprout-core/src/engram.rs b/crates/sprout-core/src/engram.rs new file mode 100644 index 000000000..8ab1a0bf2 --- /dev/null +++ b/crates/sprout-core/src/engram.rs @@ -0,0 +1,835 @@ +//! NIP-AE Agent Engrams — pure crypto + parsing primitives. +//! +//! See `docs/nips/NIP-AE.md` for the spec. This module is I/O-free: it does +//! not talk to relays or filesystems. Callers wire it to a transport and a +//! key source. +//! +//! Shared by `sprout-cli` (`sprout mem …`) and `sprout-acp` (core injection +//! at session creation). + +use hmac::digest::KeyInit; +use hmac::{Hmac, Mac}; +use nostr::nips::nip44::{self, v2::ConversationKey, Version}; +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::kind::KIND_AGENT_ENGRAM; + +/// The reserved slug for the agent's core (identity) engram. +pub const CORE_SLUG: &str = "core"; + +/// Domain prefix for the `d`-tag HMAC. Followed by a `0x00` byte and the slug. +/// Versioned independently of the NIP number; future revisions MUST change it. +pub const D_TAG_DOMAIN: &[u8] = b"agent-memory/v1/d-tag"; + +/// NIP-44 plaintext limit (bytes). Bodies whose serialized JSON exceeds this +/// MUST NOT be encrypted (spec: *Encryption*). +pub const NIP44_PLAINTEXT_MAX: usize = 65_535; + +/// Maximum slug length in bytes (spec: *Slugs*). +pub const SLUG_MAX_LEN: usize = 255; + +/// Memory slug grammar prefix. +const MEM_PREFIX: &str = "mem/"; + +/// Errors from engram operations. +#[derive(Debug, thiserror::Error)] +pub enum EngramError { + /// Slug failed the *Slugs* grammar. + #[error("invalid slug: {0}")] + InvalidSlug(String), + /// Body parsing or shape check failed. + #[error("invalid body: {0}")] + InvalidBody(String), + /// Event failed *Head selection* rule (1) — tag shape or addressing. + #[error("invalid envelope: {0}")] + InvalidEnvelope(String), + /// NIP-44 decryption failed. + #[error("decrypt failed")] + Decrypt, + /// Encryption failed. + #[error("encrypt failed: {0}")] + Encrypt(String), + /// Body exceeds the NIP-44 plaintext cap. + #[error("body exceeds {NIP44_PLAINTEXT_MAX}-byte plaintext limit ({0} bytes)")] + BodyTooLarge(usize), + /// Signing error. + #[error("sign failed: {0}")] + Sign(String), +} + +/// Validate a slug against the *Slugs* grammar. +/// +/// Returns `Ok(())` if the slug is the reserved `core` or matches: +/// `^mem/[a-z0-9][a-z0-9_-]{0,63}(/[a-z0-9][a-z0-9_-]{0,63})*$` with total +/// length ≤ 255 bytes. +pub fn validate_slug(slug: &str) -> Result<(), EngramError> { + if slug == CORE_SLUG { + return Ok(()); + } + if slug.len() > SLUG_MAX_LEN { + return Err(EngramError::InvalidSlug(format!( + "length {} exceeds {}", + slug.len(), + SLUG_MAX_LEN + ))); + } + let Some(rest) = slug.strip_prefix(MEM_PREFIX) else { + return Err(EngramError::InvalidSlug(format!( + "expected `core` or `mem/…`, got {slug:?}" + ))); + }; + if rest.is_empty() { + return Err(EngramError::InvalidSlug("empty after `mem/`".into())); + } + for (i, segment) in rest.split('/').enumerate() { + validate_segment(segment).map_err(|why| { + EngramError::InvalidSlug(format!("segment {} ({:?}): {why}", i + 1, segment)) + })?; + } + Ok(()) +} + +fn validate_segment(s: &str) -> Result<(), &'static str> { + if s.is_empty() { + return Err("empty"); + } + if s.len() > 64 { + return Err("longer than 64 bytes"); + } + let bytes = s.as_bytes(); + let first = bytes[0]; + if !is_lower_alnum(first) { + return Err("first byte must be [a-z0-9]"); + } + for &b in &bytes[1..] { + if !(is_lower_alnum(b) || b == b'_' || b == b'-') { + return Err("only [a-z0-9_-] allowed after the first byte"); + } + } + Ok(()) +} + +const fn is_lower_alnum(b: u8) -> bool { + matches!(b, b'a'..=b'z' | b'0'..=b'9') +} + +/// Normalize a user-supplied shorthand to a full slug. +/// +/// `core` is returned verbatim. Otherwise, if the input does not already +/// start with `mem/`, the prefix is added. The result is validated; the +/// caller still gets an `InvalidSlug` if the shorthand is not a valid slug. +pub fn normalize_slug(raw: &str) -> Result { + let slug = if raw == CORE_SLUG || raw.starts_with(MEM_PREFIX) { + raw.to_string() + } else { + format!("{MEM_PREFIX}{raw}") + }; + validate_slug(&slug)?; + Ok(slug) +} + +/// Derive the conversation key `K_c` for the agent ↔ owner pair (NIP-44 v2). +/// +/// `K_c` is symmetric: `derive(seckey_a, pubkey_o) == derive(seckey_o, pubkey_a)`. +pub fn conversation_key(my_seckey: &SecretKey, their_pubkey: &PublicKey) -> ConversationKey { + ConversationKey::derive(my_seckey, their_pubkey) +} + +/// Compute the `d` tag for a slug under a conversation key. +/// +/// `d = lower_hex(HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug))`, +/// 64 hex characters. +pub fn d_tag(k_c: &ConversationKey, slug: &str) -> String { + // HMAC-SHA256 accepts a key of any byte length; `new_from_slice` only + // returns `Err` for fixed-length MAC variants. This is infallible for + // SHA-256 and propagating it would just add noise at every call site. + let mut mac = as KeyInit>::new_from_slice(k_c.as_bytes()) + .expect("HMAC-SHA256 is keyed-prefix MAC; new_from_slice cannot fail"); + mac.update(D_TAG_DOMAIN); + mac.update(&[0u8]); + mac.update(slug.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +// ── Bodies ────────────────────────────────────────────────────────────────── + +/// A decoded engram body. The slug discriminates the variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Body { + /// `slug == "core"` — agent identity surface. + Core { + /// Free-form UTF-8 maintained by the agent. + profile: String, + }, + /// `slug == "mem/…"` — one logical entry. `value = None` is a tombstone. + Memory { + /// The slug this body addresses. + slug: String, + /// The entry's value, or `None` for a tombstone. + value: Option, + }, +} + +impl Body { + /// Return the slug this body addresses. + pub fn slug(&self) -> &str { + match self { + Body::Core { .. } => CORE_SLUG, + Body::Memory { slug, .. } => slug, + } + } + + /// `true` if this is a tombstone (memory with `value = null`). + pub fn is_tombstone(&self) -> bool { + matches!(self, Body::Memory { value: None, .. }) + } + + /// Serialize to the exact JSON encoding the spec specifies for the body + /// passed to NIP-44. Whitespace-free, slug first. + pub fn to_json_bytes(&self) -> Vec { + // We hand-roll the JSON so the *Reference test vectors* match + // byte-for-byte (`{"slug":"…","value":"…"}` / `{"slug":"core","profile":"…"}`). + let mut out = String::with_capacity(64); + out.push('{'); + out.push_str("\"slug\":"); + write_json_string(&mut out, self.slug()); + match self { + Body::Memory { value: Some(v), .. } => { + out.push_str(",\"value\":"); + write_json_string(&mut out, v); + } + Body::Memory { value: None, .. } => { + out.push_str(",\"value\":null"); + } + Body::Core { profile } => { + out.push_str(",\"profile\":"); + write_json_string(&mut out, profile); + } + } + out.push('}'); + out.into_bytes() + } + + /// Parse a body from its decrypted JSON bytes. Rejects duplicate object + /// member names (spec: *Head selection* rule (3)). Unknown fields are + /// ignored. The body's `slug` is validated. + pub fn from_json_bytes(bytes: &[u8]) -> Result { + // serde_json by default *accepts* duplicate keys (last wins). The spec + // requires rejection, so we deserialize through a wrapper that walks + // the tree once and fails on the first repeated member name at any + // nesting depth. + let raw = parse_strict_json(bytes)?; + + let obj = match raw { + serde_json::Value::Object(m) => m, + _ => return Err(EngramError::InvalidBody("top-level not an object".into())), + }; + let slug = match obj.get("slug") { + Some(serde_json::Value::String(s)) => s.clone(), + Some(_) => return Err(EngramError::InvalidBody("`slug` is not a string".into())), + None => return Err(EngramError::InvalidBody("missing `slug`".into())), + }; + validate_slug(&slug)?; + if slug == CORE_SLUG { + let profile = match obj.get("profile") { + Some(serde_json::Value::String(s)) => s.clone(), + Some(_) => { + return Err(EngramError::InvalidBody("`profile` is not a string".into())) + } + None => return Err(EngramError::InvalidBody("core missing `profile`".into())), + }; + Ok(Body::Core { profile }) + } else { + let value = match obj.get("value") { + Some(serde_json::Value::String(s)) => Some(s.clone()), + Some(serde_json::Value::Null) => None, + Some(_) => return Err(EngramError::InvalidBody("`value` is not a string".into())), + None => return Err(EngramError::InvalidBody("memory missing `value`".into())), + }; + Ok(Body::Memory { slug, value }) + } + } +} + +/// Minimal JSON-string encoder per RFC 8259 §7. Escapes `"` `\` and the +/// required control chars (`\b \f \n \r \t` + `\u00XX` for the rest). +fn write_json_string(out: &mut String, s: &str) { + out.push('"'); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); +} + +/// Parse a JSON document, rejecting duplicate member names at any nesting +/// depth. Returns the parsed `serde_json::Value` on success. +/// +/// `serde_json::from_slice` happily last-wins on duplicates, but *Head +/// selection* rule (3) requires strict rejection. We get correct behaviour +/// by feeding the deserializer a custom visitor for maps that tracks seen +/// keys; arrays / scalars fall through to the default `Value` visitor. +fn parse_strict_json(bytes: &[u8]) -> Result { + use serde::de::{DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; + use serde_json::Value; + use std::collections::HashSet; + use std::fmt; + + struct StrictValue; + + impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + fn deserialize>(self, d: D) -> Result { + d.deserialize_any(StrictValue) + } + } + + impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("any valid JSON value (objects must have unique keys)") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite float")) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(StrictValue) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(v) = seq.next_element_seed(StrictValue)? { + out.push(v); + } + Ok(Value::Array(out)) + } + + fn visit_map>(self, mut map: A) -> Result { + use serde::de::Error; + let mut seen: HashSet = HashSet::new(); + let mut out = serde_json::Map::new(); + while let Some(k) = map.next_key::()? { + if !seen.insert(k.clone()) { + return Err(A::Error::custom(format!( + "duplicate object member name: {k}" + ))); + } + let v = map.next_value_seed(StrictValue)?; + out.insert(k, v); + } + Ok(Value::Object(out)) + } + } + + let mut de = serde_json::Deserializer::from_slice(bytes); + let v = StrictValue + .deserialize(&mut de) + .map_err(|e| EngramError::InvalidBody(format!("invalid JSON body: {e}")))?; + de.end() + .map_err(|e| EngramError::InvalidBody(format!("trailing data after JSON body: {e}")))?; + Ok(v) +} + +// ── Envelope build / parse ────────────────────────────────────────────────── + +/// Build a signed `kind:30174` event for a given body. +/// +/// * `created_at` is the timestamp to sign — callers MUST supply a value +/// respecting the *Writing* monotonic rule (`max(now, T_head + 1)`). +/// * Returns `BodyTooLarge` if the serialized body exceeds 65,535 bytes. +pub fn build_event( + agent_keys: &Keys, + owner_pubkey: &PublicKey, + body: &Body, + created_at: u64, +) -> Result { + let plaintext = body.to_json_bytes(); + if plaintext.len() > NIP44_PLAINTEXT_MAX { + return Err(EngramError::BodyTooLarge(plaintext.len())); + } + // `to_json_bytes` only emits ASCII control chars or `&str` bytes, so + // this is always Ok. We still verify rather than `.expect()` so a future + // change to the serializer can't silently introduce a panic on the hot + // path. + let plaintext_str = std::str::from_utf8(&plaintext) + .map_err(|e| EngramError::Encrypt(format!("body JSON not UTF-8: {e}")))?; + + let k_c = conversation_key(agent_keys.secret_key(), owner_pubkey); + let ciphertext = nip44::encrypt( + agent_keys.secret_key(), + owner_pubkey, + plaintext_str, + Version::V2, + ) + .map_err(|e| EngramError::Encrypt(e.to_string()))?; + + let d = d_tag(&k_c, body.slug()); + let tags = vec![ + Tag::parse(&["d", &d]).map_err(|e| EngramError::Encrypt(e.to_string()))?, + Tag::parse(&["p", &owner_pubkey.to_hex()]) + .map_err(|e| EngramError::Encrypt(e.to_string()))?, + ]; + + EventBuilder::new(Kind::Custom(KIND_AGENT_ENGRAM as u16), ciphertext, tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(agent_keys) + .map_err(|e| EngramError::Sign(e.to_string())) +} + +/// Validate an event against *Head selection* rules (1) and (5) and return +/// the decoded body. Caller must verify the signature beforehand (NIP-44 +/// requires outer-signature-before-decrypt; `nostr::Event::verify_signature` +/// or NIP-01 ingest path handles this). +/// +/// * `event` — the candidate event. +/// * `expected_agent` — the agent pubkey the event's `pubkey` field must equal. +/// * `expected_owner` — the owner pubkey the event's `p` tag must contain. +/// * `my_seckey` / `their_pubkey` — the *NIP-44 ECDH pair* the caller holds. +/// For an agent reading its own engrams: `my_seckey = seckey_a`, +/// `their_pubkey = pubkey_o`. For an owner reading the agent's engrams: +/// `my_seckey = seckey_o`, `their_pubkey = pubkey_a`. Either yields the +/// same `K_c`. +pub fn validate_and_decrypt( + event: &Event, + expected_agent: &PublicKey, + expected_owner: &PublicKey, + my_seckey: &SecretKey, + their_pubkey: &PublicKey, +) -> Result { + if event.kind.as_u16() as u32 != KIND_AGENT_ENGRAM { + return Err(EngramError::InvalidEnvelope(format!( + "wrong kind: {}", + event.kind.as_u16() + ))); + } + if &event.pubkey != expected_agent { + return Err(EngramError::InvalidEnvelope( + "pubkey != expected_agent".into(), + )); + } + let mut d_tags = event.tags.iter().filter(|t| t.kind().to_string() == "d"); + let d_value = d_tags + .next() + .and_then(|t| t.content().map(|s| s.to_string())) + .ok_or_else(|| EngramError::InvalidEnvelope("missing d tag".into()))?; + if d_tags.next().is_some() { + return Err(EngramError::InvalidEnvelope("multiple d tags".into())); + } + // Spec: d = lower_hex(HMAC...). Anything else is non-canonical and we + // refuse to interoperate with it. + if d_value.len() != 64 + || !d_value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(EngramError::InvalidEnvelope( + "d tag must be 64 lowercase hex chars".into(), + )); + } + let mut p_tags = event.tags.iter().filter(|t| t.kind().to_string() == "p"); + let p_value = p_tags + .next() + .and_then(|t| t.content().map(|s| s.to_string())) + .ok_or_else(|| EngramError::InvalidEnvelope("missing p tag".into()))?; + if p_tags.next().is_some() { + return Err(EngramError::InvalidEnvelope("multiple p tags".into())); + } + if p_value.eq_ignore_ascii_case(&expected_owner.to_hex()) { + // ok + } else { + return Err(EngramError::InvalidEnvelope( + "p tag != expected_owner".into(), + )); + } + + // Decrypt. `K_c` is symmetric per NIP-44, so the caller's `(my_seckey, + // their_pubkey)` pair yields the same conversation key regardless of + // whether the caller is the agent or the owner. + let plaintext = nip44::decrypt(my_seckey, their_pubkey, &event.content) + .map_err(|_| EngramError::Decrypt)?; + let body = Body::from_json_bytes(plaintext.as_bytes())?; + + // Rule (4): body slug re-derives to the event's d tag. + let k_c = conversation_key(my_seckey, their_pubkey); + let derived = d_tag(&k_c, body.slug()); + if derived != d_value { + return Err(EngramError::InvalidEnvelope( + "body slug does not re-derive to d tag".into(), + )); + } + Ok(body) +} + +/// Pick the head from a set of events targeting the same slug — greatest +/// `created_at`, ties broken by lowest event id per NIP-01. +/// +/// Caller is responsible for validating each event (kind/tags/sig/decrypt) +/// before passing it in; this function only does the LWW tiebreak. +pub fn select_head(events: I) -> Option +where + I: IntoIterator, +{ + events.into_iter().reduce(|a, b| { + let a_ts = a.created_at.as_u64(); + let b_ts = b.created_at.as_u64(); + if b_ts > a_ts { + return b; + } + if a_ts > b_ts { + return a; + } + // Tie — lower id wins (lexicographic on hex == byte-wise on bytes). + if b.id.to_hex() < a.id.to_hex() { + b + } else { + a + } + }) +} + +/// Compute `created_at` for a new write per the *Writing* monotonic rule: +/// `max(now, prior_head_created_at + 1)`. +pub fn monotonic_created_at(now: u64, prior_head: Option) -> u64 { + match prior_head { + Some(t) => now.max(t.saturating_add(1)), + None => now, + } +} + +/// Wire representation for `sprout mem ls`: one entry per non-tombstone +/// memory slug (`core` is excluded by the listing procedure). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Listing { + /// The slug (e.g. `mem/notes/today`). + pub slug: String, + /// Event id of the current head. + pub event_id: String, + /// `created_at` of the current head. + pub created_at: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys_from_hex(s: &str) -> Keys { + Keys::parse(s).unwrap() + } + + // ── Reference test vectors from docs/nips/NIP-AE.md §"Reference test + // vectors". Pinning these as CI invariants is the single best + // interop guarantee for this implementation. ── + + const SECKEY_A: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const SECKEY_O: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + const PUBKEY_A: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const PUBKEY_O: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + + const K_C_HEX: &str = "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d"; + + const D_CORE: &str = "bdc233238ffe52e272b44cc233c8f33a2bc510b08be04495b225964283be4a90"; + const D_EXAMPLE: &str = "72d4f9629106451505d7d341ea85bb3ebad4f654fcfd2aad100d5a35f8a85cba"; + const D_NOTES: &str = "31651571a312780cfdc1f0b706b682ac9f3f51a053e8dca76fe57710bae5a4d4"; + + #[test] + fn pubkeys_match_spec() { + assert_eq!(keys_from_hex(SECKEY_A).public_key().to_hex(), PUBKEY_A); + assert_eq!(keys_from_hex(SECKEY_O).public_key().to_hex(), PUBKEY_O); + } + + #[test] + fn conversation_key_matches_spec() { + let a = keys_from_hex(SECKEY_A); + let o = keys_from_hex(SECKEY_O); + let k_c_ao = conversation_key(a.secret_key(), &o.public_key()); + let k_c_oa = conversation_key(o.secret_key(), &a.public_key()); + assert_eq!(hex::encode(k_c_ao.as_bytes()), K_C_HEX, "agent-side K_c"); + assert_eq!(hex::encode(k_c_oa.as_bytes()), K_C_HEX, "owner-side K_c"); + } + + #[test] + fn d_tags_match_spec() { + let a = keys_from_hex(SECKEY_A); + let o = keys_from_hex(SECKEY_O); + let k_c = conversation_key(a.secret_key(), &o.public_key()); + assert_eq!(d_tag(&k_c, "core"), D_CORE); + assert_eq!(d_tag(&k_c, "mem/example"), D_EXAMPLE); + assert_eq!(d_tag(&k_c, "mem/notes/2026-05-12"), D_NOTES); + } + + #[test] + fn body_round_trips_byte_exact() { + // Memory with value. + let b = Body::Memory { + slug: "mem/example".into(), + value: Some("hello, agent memory".into()), + }; + assert_eq!( + b.to_json_bytes(), + br#"{"slug":"mem/example","value":"hello, agent memory"}"#.to_vec() + ); + // Memory with reference. + let b = Body::Memory { + slug: "mem/notes/2026-05-12".into(), + value: Some("meeting note: [[mem/example]]".into()), + }; + assert_eq!( + b.to_json_bytes(), + br#"{"slug":"mem/notes/2026-05-12","value":"meeting note: [[mem/example]]"}"#.to_vec() + ); + // Tombstone. + let b = Body::Memory { + slug: "mem/example".into(), + value: None, + }; + assert_eq!( + b.to_json_bytes(), + br#"{"slug":"mem/example","value":null}"#.to_vec() + ); + // Core. + let b = Body::Core { + profile: "test agent. see [[mem/example]] and [[mem/notes/2026-05-12]].".into(), + }; + assert_eq!( + b.to_json_bytes(), + br#"{"slug":"core","profile":"test agent. see [[mem/example]] and [[mem/notes/2026-05-12]]."}"#.to_vec() + ); + } + + #[test] + fn body_parse_rejects_duplicate_keys() { + let dup = br#"{"slug":"mem/x","slug":"mem/y","value":"v"}"#; + let err = Body::from_json_bytes(dup).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("duplicate"), "got: {msg}"); + } + + #[test] + fn body_parse_ignores_unknown_fields() { + let body = br#"{"slug":"mem/x","value":"v","unknown":42,"future":{"nested":"ok"}}"#; + let b = Body::from_json_bytes(body).unwrap(); + assert!(matches!(b, Body::Memory { value: Some(_), .. })); + } + + #[test] + fn body_parse_accepts_arrays_of_repeated_strings() { + // Repeats inside an *array* aren't object-member duplicates and must + // be accepted. The previous hand-rolled scanner incorrectly flagged + // this as a dup. + let body = br#"{"slug":"mem/x","value":"v","future":["slug","slug","value"]}"#; + let b = Body::from_json_bytes(body).unwrap(); + assert!(matches!(b, Body::Memory { value: Some(_), .. })); + } + + #[test] + fn body_parse_accepts_surrogate_pair_escapes() { + // `\uD83D\uDE00` (😀) is a valid UTF-16 surrogate pair that serde_json + // decodes correctly. The previous hand-rolled scanner rejected each + // half as an invalid codepoint. + let body = br#"{"slug":"mem/x","value":"hi \uD83D\uDE00"}"#; + let b = Body::from_json_bytes(body).unwrap(); + match b { + Body::Memory { + value: Some(v), + slug, + } => { + assert_eq!(slug, "mem/x"); + assert_eq!(v, "hi \u{1F600}"); + } + _ => panic!("expected Memory"), + } + } + + #[test] + fn body_parse_rejects_duplicates_at_any_depth() { + // Object inside an unknown field with duplicate keys: still rejected. + let dup = br#"{"slug":"mem/x","value":"v","nested":{"k":1,"k":2}}"#; + let err = Body::from_json_bytes(dup).unwrap_err(); + assert!(format!("{err}").contains("duplicate"), "got: {err}"); + } + + #[test] + fn validate_slug_accepts_grammar() { + for ok in [ + "core", + "mem/x", + "mem/x-y_z", + "mem/0", + "mem/notes/2026-05-12", + "mem/a/b/c", + ] { + assert!(validate_slug(ok).is_ok(), "{ok} should be valid"); + } + } + + #[test] + fn validate_slug_rejects_garbage() { + for bad in [ + "", "MEM/x", "mem/", "mem//x", "mem/-x", "mem/_x", "mem/x/-y", "mem/x/", "memx", + "mem/x/Y", + "mem/x.y", + // 64-byte boundary: first byte then 64 more = 65 -> too long + ] { + assert!(validate_slug(bad).is_err(), "{bad:?} should be rejected"); + } + } + + #[test] + fn normalize_slug_adds_mem_prefix() { + assert_eq!(normalize_slug("foo").unwrap(), "mem/foo"); + assert_eq!(normalize_slug("core").unwrap(), "core"); + assert_eq!(normalize_slug("mem/bar").unwrap(), "mem/bar"); + assert!(normalize_slug("Foo").is_err()); + } + + #[test] + fn monotonic_clock_rule() { + assert_eq!(monotonic_created_at(100, None), 100); + assert_eq!(monotonic_created_at(100, Some(50)), 100); + assert_eq!(monotonic_created_at(100, Some(100)), 101); + assert_eq!(monotonic_created_at(100, Some(200)), 201); + } + + #[test] + fn select_head_lww_with_id_tiebreak() { + // We build three events with the same kind/pubkey/d tag and check + // (1) greater created_at wins, (2) ties broken by lower id. + let agent = keys_from_hex(SECKEY_A); + let owner = keys_from_hex(SECKEY_O); + let body = Body::Memory { + slug: "mem/example".into(), + value: Some("v".into()), + }; + let e1 = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap(); + let e2 = build_event(&agent, &owner.public_key(), &body, 1_700_000_001).unwrap(); + let pick = select_head([e1.clone(), e2.clone()]).unwrap(); + assert_eq!(pick.id, e2.id); + } + + #[test] + fn round_trip_event_validates_and_decrypts() { + let agent = keys_from_hex(SECKEY_A); + let owner = keys_from_hex(SECKEY_O); + let original = Body::Memory { + slug: "mem/example".into(), + value: Some("hello".into()), + }; + let event = build_event(&agent, &owner.public_key(), &original, 1_700_000_000).unwrap(); + let decoded = validate_and_decrypt( + &event, + &agent.public_key(), + &owner.public_key(), + owner.secret_key(), + &agent.public_key(), + ) + .unwrap(); + // And the agent-side decrypt path must also work. + let decoded_agent = validate_and_decrypt( + &event, + &agent.public_key(), + &owner.public_key(), + agent.secret_key(), + &owner.public_key(), + ) + .unwrap(); + assert_eq!(decoded_agent, decoded); + assert_eq!(decoded, original); + } + + #[test] + fn validate_and_decrypt_rejects_non_lowercase_d_tag() { + // Build a perfectly valid event but tamper the d tag to uppercase + // before signing. `validate_and_decrypt` must refuse, because the + // spec requires lowercase hex and otherwise two stringly-different + // d-tags would map to the same head. + let agent = keys_from_hex(SECKEY_A); + let owner = keys_from_hex(SECKEY_O); + let body = Body::Memory { + slug: "mem/example".into(), + value: Some("hi".into()), + }; + let ev = build_event(&agent, &owner.public_key(), &body, 1).unwrap(); + // Re-sign with the d tag uppercased. + let d = ev + .tags + .iter() + .find(|t| t.kind().to_string() == "d") + .and_then(|t| t.content().map(|s| s.to_string())) + .unwrap(); + let upper_d = d.to_uppercase(); + let tags = vec![ + Tag::parse(&["d", &upper_d]).unwrap(), + Tag::parse(&["p", &owner.public_key().to_hex()]).unwrap(), + ]; + let tampered = EventBuilder::new( + Kind::Custom(KIND_AGENT_ENGRAM as u16), + ev.content.clone(), + tags, + ) + .custom_created_at(ev.created_at) + .sign_with_keys(&agent) + .unwrap(); + let err = validate_and_decrypt( + &tampered, + &agent.public_key(), + &owner.public_key(), + agent.secret_key(), + &owner.public_key(), + ) + .unwrap_err(); + assert!(matches!(err, EngramError::InvalidEnvelope(_))); + } + + #[test] + fn body_too_large_rejected_at_build_time() { + let agent = keys_from_hex(SECKEY_A); + let owner = keys_from_hex(SECKEY_O); + // Build a value whose JSON representation just barely exceeds the limit. + let huge = "a".repeat(NIP44_PLAINTEXT_MAX); + let body = Body::Memory { + slug: "mem/example".into(), + value: Some(huge), + }; + let err = build_event(&agent, &owner.public_key(), &body, 1).unwrap_err(); + assert!(matches!(err, EngramError::BodyTooLarge(_))); + } +} diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index a0f03f801..3ac6bcec1 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -45,6 +45,13 @@ pub const KIND_HTTP_AUTH: u32 = 27235; /// Agent metadata + owner reference (replaceable, agent-authored). pub const KIND_AGENT_PROFILE: u32 = 10100; +/// NIP-AE: Agent Engram (parameterized replaceable, agent-authored). +/// +/// Encrypted memory record for AI agents. Addressed by `(pubkey_a, kind, d_tag)`, +/// where `d_tag` is an HMAC over the agent↔owner conversation key. See +/// `docs/nips/NIP-AE.md` and [`crate::engram`]. +pub const KIND_AGENT_ENGRAM: u32 = 30174; + // NIP-29 group admin events /// NIP-29: Add a user to a group. pub const KIND_NIP29_PUT_USER: u32 = 9000; @@ -321,6 +328,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIFT_WRAP, KIND_FILE_METADATA, KIND_AGENT_PROFILE, + KIND_AGENT_ENGRAM, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP29_EDIT_METADATA, diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index d3eeecb72..a0395a060 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -7,6 +7,9 @@ /// Channel and membership enums shared across crates. pub mod channel; +/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, +/// body parse/serialize, envelope build/validate, head selection. +pub mod engram; /// Relay-side error types. pub mod error; /// Relay-side event wrapper with verification tracking. diff --git a/crates/sprout-relay/src/api/bridge.rs b/crates/sprout-relay/src/api/bridge.rs index 2c2a429f7..cb4f6d757 100644 --- a/crates/sprout-relay/src/api/bridge.rs +++ b/crates/sprout-relay/src/api/bridge.rs @@ -200,6 +200,12 @@ pub async fn query_events( "restricted: p-gated kinds require #p tag matching your pubkey", )); } + if !crate::handlers::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: agent-engram reads require authors=[self] or #p=[self]", + )); + } // Get channels this user can access — same enforcement as WS REQ handler. let accessible_channels = state @@ -294,6 +300,12 @@ pub async fn count_events( "restricted: p-gated kinds require #p tag matching your pubkey", )); } + if !crate::handlers::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: agent-engram reads require authors=[self] or #p=[self]", + )); + } // Get channels this user can access. let accessible_channels = state @@ -380,6 +392,34 @@ pub async fn count_events( // ── NIP-50 search via HTTP bridge ──────────────────────────────────────────── +/// Decide whether a search hit should be returned to the caller. +/// +/// Mirrors the WS NIP-50 path's post-filter step in `handlers/req.rs`: +/// Typesense receives only the kind/authors/time pushdown, so any other filter +/// constraint (`#p`, `#h`, `#e`, `#d`, `ids`, …) must be enforced here against +/// the full stored event. Without this, an authorized engram search such as +/// `{"kinds":[30174],"#p":[self]}` would leak text-matching envelopes whose +/// `#p` belongs to a different owner — the NIP-AE read gate at the filter +/// layer would be bypassed for `/query`. +/// +/// `accessible_channels` is the caller's channel scope; channel-scoped hits +/// outside that set are rejected regardless of NIP-01 match. +fn search_hit_accepted( + filter: &nostr::Filter, + stored: &sprout_core::StoredEvent, + accessible_channels: &[uuid::Uuid], +) -> bool { + if !sprout_core::filter::filters_match(std::slice::from_ref(filter), stored) { + return false; + } + if let Some(ch_id) = stored.channel_id { + if !accessible_channels.contains(&ch_id) { + return false; + } + } + true +} + /// Handle search filters by routing to Typesense, then fetching full events from DB. /// Returns first page of results (no pagination for bridge MVP). async fn handle_bridge_search( @@ -499,11 +539,8 @@ async fn handle_bridge_search( Some(ev) => ev, None => continue, }; - // Channel access post-filter. - if let Some(ch_id) = stored.channel_id { - if !accessible_channels.contains(&ch_id) { - continue; - } + if !search_hit_accepted(filter, stored, accessible_channels) { + continue; } // Dedup across filters. if !seen_ids.insert(id_array) { @@ -735,3 +772,107 @@ async fn synthesize_presence(state: &AppState, filters: &[nostr::Filter]) -> Opt Some(events) } + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; + + /// Build a kind:30174 engram envelope authored by `agent`, tagged with `owner`. + fn engram_envelope(agent: &Keys, owner_hex: &str) -> sprout_core::StoredEvent { + let d_tag = Tag::custom( + nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)), + ["abcd1234"], + ); + let p_tag = Tag::custom( + nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), + [owner_hex], + ); + let ev = EventBuilder::new(Kind::Custom(30174), "engram body", [d_tag, p_tag]) + .sign_with_keys(agent) + .expect("sign engram"); + sprout_core::StoredEvent::new(ev, None) + } + + /// Regression test for the NIP-AE `/query` search leak (PR #593 review). + /// + /// Setup: two engram envelopes by different agents for different owners. + /// An authorized search for `{kinds:[30174], #p:[owner_a]}` would be + /// approved by the engram gate (owner_a is querying engrams addressed to + /// them). Typesense's pushdown only carries `kind:=[30174]`, so the + /// envelope for owner_b can come back as a text-match hit. The post-filter + /// in `search_hit_accepted` must reject it. + #[test] + fn search_hit_rejects_envelope_with_mismatched_p_tag() { + let agent_a = Keys::generate(); + let agent_b = Keys::generate(); + let owner_a = Keys::generate().public_key().to_hex(); + let owner_b = Keys::generate().public_key().to_hex(); + + let env_for_a = engram_envelope(&agent_a, &owner_a); + let env_for_b = engram_envelope(&agent_b, &owner_b); + + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let filter = nostr::Filter::new() + .kind(Kind::Custom(30174)) + .custom_tag(p_tag, [&owner_a]); + + assert!( + search_hit_accepted(&filter, &env_for_a, &[]), + "envelope addressed to owner_a must be returned" + ); + assert!( + !search_hit_accepted(&filter, &env_for_b, &[]), + "envelope addressed to owner_b must NOT be returned for a #p=[owner_a] search" + ); + } + + /// `authors=[agent_a]` search must not return an envelope authored by agent_b, + /// even if Typesense's text match would otherwise surface it. (Typesense does + /// carry an `authors` pushdown today, so this is defence-in-depth; mirroring + /// the WS contract.) + #[test] + fn search_hit_rejects_event_with_mismatched_author() { + let agent_a = Keys::generate(); + let agent_b = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + + let env_a = engram_envelope(&agent_a, &owner); + let env_b = engram_envelope(&agent_b, &owner); + + let filter = nostr::Filter::new() + .kind(Kind::Custom(30174)) + .author(agent_a.public_key()); + + assert!(search_hit_accepted(&filter, &env_a, &[])); + assert!( + !search_hit_accepted(&filter, &env_b, &[]), + "authors=[agent_a] search must not return events authored by agent_b" + ); + } + + /// Channel-scoped events outside the caller's accessible-channel set are + /// rejected by the post-filter regardless of NIP-01 match. + #[test] + fn search_hit_rejects_inaccessible_channel() { + let agent = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let mut stored = engram_envelope(&agent, &owner); + let scoped_channel = uuid::Uuid::new_v4(); + stored.channel_id = Some(scoped_channel); + + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let filter = nostr::Filter::new() + .kind(Kind::Custom(30174)) + .custom_tag(p_tag, [&owner]); + + assert!( + !search_hit_accepted(&filter, &stored, &[]), + "channel-scoped hit must be rejected when caller has no channel access" + ); + assert!( + search_hit_accepted(&filter, &stored, &[scoped_channel]), + "channel-scoped hit must be accepted when caller has access to that channel" + ); + } +} diff --git a/crates/sprout-relay/src/handlers/count.rs b/crates/sprout-relay/src/handlers/count.rs index f7be69f4c..953b07ffb 100644 --- a/crates/sprout-relay/src/handlers/count.rs +++ b/crates/sprout-relay/src/handlers/count.rs @@ -54,6 +54,13 @@ pub async fn handle_count( )); return; } + if !super::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: agent-engram reads require authors=[self] or #p=[self]", + )); + return; + } // Get channels this user can access — same enforcement as WS REQ handler. let accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await { diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index ddfea3db6..47235e9b7 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -12,23 +12,23 @@ use uuid::Uuid; use nostr::Event; use sprout_auth::Scope; use sprout_core::kind::{ - event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_APPROVAL_DENY, - KIND_APPROVAL_GRANT, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, - KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FORUM_COMMENT, KIND_FORUM_POST, - KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, - KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, - KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, - KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, - KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, - KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, - KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, - KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, - KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, - KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, + KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST, + KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FORUM_COMMENT, + KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, + KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, + KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, + KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, }; use sprout_core::verification::verify_event; @@ -150,7 +150,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), - KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS => Ok(Scope::UsersWrite), + KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM => { + Ok(Scope::UsersWrite) + } KIND_DELETION | KIND_REACTION | KIND_GIFT_WRAP @@ -300,6 +302,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_LONG_FORM | KIND_USER_STATUS | KIND_READ_STATE + // NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped. + | KIND_AGENT_ENGRAM // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -767,6 +771,143 @@ fn validate_diff_event(event: &Event) -> Result<(), String> { Ok(()) } +/// Validate the public envelope of a NIP-AE `kind:30174` event before it +/// reaches NIP-33 parameterized replacement. +/// +/// We deliberately do this here (not in the d-tag length check downstream) +/// because a malformed envelope can otherwise *replace* a valid head in +/// storage and then be invisible to readers querying `#p`. The relay sees +/// no plaintext, but it can — and must — enforce the public tag shape: +/// +/// * exactly one `d` tag with a 64-hex value (`d_tag = lower_hex(HMAC...)`), +/// * exactly one `p` tag with a 64-hex pubkey (the owner counterparty). +/// +/// Content is opaque NIP-44 ciphertext; we do not parse it. +fn validate_engram_envelope(event: &Event) -> Result<(), String> { + let mut d_tags: Vec<&str> = Vec::new(); + let mut p_tags: Vec<&str> = Vec::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() < 2 { + continue; + } + match parts[0].as_str() { + "d" => d_tags.push(&parts[1]), + "p" => p_tags.push(&parts[1]), + _ => {} + } + } + if d_tags.len() != 1 { + return Err(format!( + "agent-engram event must have exactly one `d` tag (got {})", + d_tags.len() + )); + } + if p_tags.len() != 1 { + return Err(format!( + "agent-engram event must have exactly one `p` tag (got {})", + p_tags.len() + )); + } + let d = d_tags[0]; + if d.len() != 64 + || !d + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("agent-engram `d` tag must be 64 lowercase hex chars".to_string()); + } + let p = p_tags[0]; + // Lowercase-only: readers query `#p` with `owner.to_hex()` (lowercase) and + // Nostr tag matching is byte-exact. Accepting uppercase here would let a + // submitter replace the lowercase head with an event that subsequent + // lowercase-`#p` queries cannot see — silently bricking the slug. + if p.len() != 64 + || !p + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("agent-engram `p` tag must be 64 lowercase hex chars (pubkey)".to_string()); + } + // Content must be a syntactically plausible NIP-44 v2 payload. We do not + // (and cannot) verify the MAC at the relay, but we can reject obvious + // garbage so a malformed event cannot supersede a valid head via NIP-33 + // replacement and then be silently discarded by readers. + validate_engram_nip44_content(&event.content)?; + Ok(()) +} + +/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. +/// +/// Checks: +/// - Non-empty. +/// - Standard base64 alphabet only (A-Z, a-z, 0-9, +, /, =), with padding only +/// at the end and total length a multiple of 4. +/// - Decoded length >= 99 bytes (1 version + 32 nonce + 32 MAC + minimum 34 +/// bytes of length-prefixed padded ciphertext required by NIP-44 v2). +/// - First decoded byte is `0x02` (NIP-44 version 2). +/// +/// This is an envelope sanity check, not full validation: the MAC and actual +/// decryption happen at the reader. The intent is to refuse obvious junk so a +/// malformed event cannot win NIP-33 replacement against a valid head and then +/// be silently skipped by `validate_and_decrypt`. Mirrors the validator in +/// `sprout-pair-relay::validate_nip44_content`. +fn validate_engram_nip44_content(content: &str) -> Result<(), String> { + if content.is_empty() { + return Err("agent-engram content must not be empty (NIP-44 ciphertext)".to_string()); + } + let bytes = content.as_bytes(); + let len = bytes.len(); + if !len.is_multiple_of(4) { + return Err("agent-engram content is not valid base64 (length)".to_string()); + } + let mut pad_count = 0usize; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => { + if pad_count > 0 { + return Err("agent-engram content is not valid base64".to_string()); + } + } + b'=' => { + if i < len - 2 { + return Err("agent-engram content is not valid base64".to_string()); + } + pad_count += 1; + if pad_count > 2 { + return Err("agent-engram content is not valid base64".to_string()); + } + } + _ => return Err("agent-engram content is not valid base64".to_string()), + } + } + let decoded_len = (len / 4) * 3 - pad_count; + if decoded_len < 99 { + return Err("agent-engram content too short for NIP-44 v2".to_string()); + } + let b64_val = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let v0 = + b64_val(bytes[0]).ok_or_else(|| "agent-engram content is not valid base64".to_string())?; + let v1 = + b64_val(bytes[1]).ok_or_else(|| "agent-engram content is not valid base64".to_string())?; + let first_byte = (v0 << 2) | (v1 >> 4); + if first_byte != 0x02 { + return Err( + "agent-engram content is not NIP-44 v2 (expected 0x02 version prefix)".to_string(), + ); + } + Ok(()) +} + // ── The pipeline ───────────────────────────────────────────────────────────── /// Ingest a signed Nostr event through the full validation pipeline. @@ -1164,6 +1305,12 @@ pub async fn ingest_event( validate_diff_event(&event).map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + // ── 15a. Agent engram envelope (kind:30174) ────────────────────────── + if kind_u32 == KIND_AGENT_ENGRAM { + validate_engram_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -1693,6 +1840,7 @@ mod tests { KIND_FORUM_COMMENT, KIND_LONG_FORM, KIND_USER_STATUS, + KIND_AGENT_ENGRAM, ]; for kind in migrated { assert!( @@ -1842,4 +1990,147 @@ mod tests { let event = make_event_with_tags(5, "", &[&["e", "a".repeat(64).as_str()]]); assert_eq!(count_e_tags(&event), 1); } + + // ── NIP-AE envelope validation ─────────────────────────────────────── + + fn make_engram(tags: &[&[&str]], content: &str) -> Event { + make_event_with_tags(KIND_AGENT_ENGRAM, content, tags) + } + + /// Minimal syntactically-plausible NIP-44 v2 payload (99 zero-filled bytes + /// with the 0x02 version prefix). Real ciphertexts are larger and have real + /// MACs; the relay only checks shape, not authenticity. + fn fake_nip44_v2() -> String { + // base64(b"\x02" + b"\x00" * 98) — 132 chars, decoded length 99, + // first byte 0x02. + let mut s = String::from("Ag"); + s.push_str(&"A".repeat(130)); + s + } + + #[test] + fn engram_envelope_accepts_canonical() { + let d = "a".repeat(64); + let p = "b".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p]], &fake_nip44_v2()); + assert!(validate_engram_envelope(&ev).is_ok()); + } + + #[test] + fn engram_envelope_rejects_missing_p() { + let d = "a".repeat(64); + let ev = make_engram(&[&["d", &d]], &fake_nip44_v2()); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn engram_envelope_rejects_duplicate_p() { + let d = "a".repeat(64); + let p = "b".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p], &["p", &p]], &fake_nip44_v2()); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn engram_envelope_rejects_short_d() { + let p = "b".repeat(64); + let ev = make_engram(&[&["d", "abcd"], &["p", &p]], &fake_nip44_v2()); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + #[test] + fn engram_envelope_rejects_uppercase_d() { + let p = "b".repeat(64); + // 64 chars but uppercase — spec mandates lowercase hex. + let d = "A".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p]], &fake_nip44_v2()); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("`d` tag"), "got: {err}"); + } + + /// Regression: uppercase `p` tag must be rejected at ingest. Readers query + /// `#p` lowercase; an uppercase-tagged event that wins NIP-33 replacement + /// becomes invisible to readers, silently bricking the slug. + #[test] + fn engram_envelope_rejects_uppercase_p() { + let d = "a".repeat(64); + let p = "B".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p]], &fake_nip44_v2()); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn engram_envelope_rejects_short_p() { + let d = "a".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", "abcd"]], &fake_nip44_v2()); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("`p` tag"), "got: {err}"); + } + + #[test] + fn engram_envelope_rejects_empty_content() { + let d = "a".repeat(64); + let p = "b".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p]], ""); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("content"), "got: {err}"); + } + + /// Regression: non-base64 content must be rejected. Otherwise a signed + /// event with `content="x"` wins NIP-33 replacement against a valid head, + /// and the new head is then skipped by `validate_and_decrypt` — making the + /// slug appear absent to readers. + #[test] + fn engram_envelope_rejects_non_base64_content() { + let d = "a".repeat(64); + let p = "b".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p]], "x"); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!( + err.contains("base64") || err.contains("too short"), + "got: {err}" + ); + } + + #[test] + fn engram_envelope_rejects_wrong_nip44_version() { + // 99 bytes of valid base64 alphabet, but first byte decodes to 0x00, + // not the NIP-44 v2 prefix 0x02. Length OK (132 chars / 99 decoded). + let d = "a".repeat(64); + let p = "b".repeat(64); + let bad = "A".repeat(132); + let ev = make_engram(&[&["d", &d], &["p", &p]], &bad); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!( + err.contains("NIP-44 v2") || err.contains("0x02"), + "got: {err}" + ); + } + + #[test] + fn engram_envelope_rejects_short_content() { + // Base64 of "Ag==" decodes to 1 byte — version prefix correct but + // way under the 99-byte floor. + let d = "a".repeat(64); + let p = "b".repeat(64); + let ev = make_engram(&[&["d", &d], &["p", &p]], "Ag=="); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("too short"), "got: {err}"); + } + + #[test] + fn engram_envelope_rejects_bad_base64_alphabet() { + let d = "a".repeat(64); + let p = "b".repeat(64); + // Contains '!' which is not in the standard base64 alphabet. Length is + // a multiple of 4 to defeat the length check. + let bad = format!("Ag!!{}", "A".repeat(128)); + let ev = make_engram(&[&["d", &d], &["p", &p]], &bad); + let err = validate_engram_envelope(&ev).unwrap_err(); + assert!(err.contains("base64"), "got: {err}"); + } } diff --git a/crates/sprout-relay/src/handlers/req.rs b/crates/sprout-relay/src/handlers/req.rs index 015ed3513..0b8152031 100644 --- a/crates/sprout-relay/src/handlers/req.rs +++ b/crates/sprout-relay/src/handlers/req.rs @@ -9,7 +9,7 @@ use hex; use nostr::Filter; use sprout_core::filter::filters_match; use sprout_core::kind::{ - KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_AGENT_ENGRAM, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, }; use sprout_db::EventQuery; @@ -90,11 +90,38 @@ pub async fn handle_req( let channel_id = extract_channel_id_from_filters(&filters); - // ── NIP-50 search: intercept BEFORE #p gating ──────────────────────────── - // Search filters are one-shot (not registered as persistent subscriptions). - // They never deliver gift wraps (not indexed) or membership notifications - // (global, no channel_id), so the #p gate below is irrelevant for them. - // Intercepting here lets clients send `{"search":"foo"}` without `kinds`. + // ── #p / engram gating for globally-stored sensitive kinds ─────────────── + // Applied BEFORE the NIP-50 search branch so that an authenticated member + // cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated + // kinds) to harvest indexed-but-globally-stored sensitive events. Search + // hits are looked up by event id and returned without the per-filter + // post-check the historical-delivery branch applies, so the gate must run + // here, up front. Only applies to GLOBAL subscriptions (channel_id = None): + // channel-scoped subs can never receive globally-stored events because of + // the fan_out() invariant in subscription.rs. + if channel_id.is_none() { + let authed_pubkey_hex = hex::encode(&pubkey_bytes); + if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: p-gated events require #p matching your pubkey", + )); + return; + } + if !engram_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: agent-engram reads require authors=[self] or #p=[self]", + )); + return; + } + } + + // ── NIP-50 search: one-shot, no persistent subscription ────────────────── + // Search filters hit Typesense and return historical hits, then EOSE. + // They are not registered for fan-out. The sensitive-kind gates above + // already ran, so an authed member cannot use search to bypass author/#p + // rules for kind:30174 or other globally-stored gated kinds. let has_search = filters.iter().any(|f| f.search.is_some()); if has_search { if filters.iter().any(|f| f.search.is_none()) { @@ -116,21 +143,6 @@ pub async fn handle_req( return; } - // ── #p gating for globally-stored sensitive kinds ───────────────────────── - // Only applies to GLOBAL subscriptions (channel_id = None). Channel-scoped - // subscriptions can never receive globally-stored events — the fan_out() - // invariant in subscription.rs prevents it. - if channel_id.is_none() { - let authed_pubkey_hex = hex::encode(&pubkey_bytes); - if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: p-gated events require #p matching your pubkey", - )); - return; - } - } - // Check channel access BEFORE registering the subscription. if let Some(ch_id) = channel_id { if !accessible_channels.contains(&ch_id) { @@ -718,6 +730,59 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: }) } +/// Authorize read access for filters that can match KIND_AGENT_ENGRAM events. +/// +/// NIP-AE engrams are global (no channel scope) and have encrypted content, +/// but their public `#p` (owner) and timestamps still leak who-pairs-with-whom +/// plus write-activity patterns. Only the agent (the event's author) or the +/// owner (the `#p` value) should be able to enumerate them. +/// +/// A filter is authorized when at least one of: +/// - `authors` is non-empty and every entry equals the authed pubkey +/// (the agent reading its own engrams), OR +/// - `#p` is non-empty and every entry equals the authed pubkey +/// (the owner reading engrams addressed to them). +/// +/// Filters with explicit `ids` are exempt — knowing the event id already +/// implies authorization (the engram event id is itself derived from the +/// signed envelope, which only the agent could have produced). +/// +/// Mixed-kind filters (e.g. `{kinds:[30174, 9]}`) are evaluated under this +/// gate when KIND_AGENT_ENGRAM is present; matching events of other kinds in +/// the same filter is also restricted, but that is the conservative choice +/// — clients should query engrams in a dedicated filter. +pub(crate) fn engram_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool { + let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); + filters.iter().all(|filter| { + // Specific-event lookups don't fish. + if filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { + return true; + } + + let can_match_engram = filter + .kinds + .as_ref() + .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_AGENT_ENGRAM)); + if !can_match_engram { + return true; + } + + let authors_ok = filter.authors.as_ref().is_some_and(|authors| { + !authors.is_empty() + && authors + .iter() + .all(|a| a.to_hex().eq_ignore_ascii_case(authed_pubkey_hex)) + }); + if authors_ok { + return true; + } + + filter.generic_tags.get(&p_tag).is_some_and(|values| { + !values.is_empty() && values.iter().all(|v| v == authed_pubkey_hex) + }) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -881,4 +946,163 @@ mod tests { "restricted tokens must not fall back to global search results" ); } + + // ── NIP-AE engram read gating ──────────────────────────────────────── + + /// Three real x-only pubkeys (valid for `PublicKey::from_hex`). Distinct, + /// so we can label them clearly in tests. + fn three_pubkeys() -> (String, String, String) { + let agent = nostr::Keys::generate().public_key().to_hex(); + let owner = nostr::Keys::generate().public_key().to_hex(); + let attacker = nostr::Keys::generate().public_key().to_hex(); + (agent, owner, attacker) + } + + #[test] + fn engram_gate_allows_agent_querying_own() { + let (agent, owner, _) = three_pubkeys(); + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .author(nostr::PublicKey::from_hex(&agent).unwrap()) + .custom_tag(p_tag, [&owner]); + assert!(engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_allows_owner_querying() { + let (agent, owner, _) = three_pubkeys(); + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + // Owner-side read: knows the agent's pubkey, queries with #p=self. + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .author(nostr::PublicKey::from_hex(&agent).unwrap()) + .custom_tag(p_tag, [&owner]); + assert!(engram_filters_authorized(&[f], &owner)); + } + + #[test] + fn engram_gate_allows_owner_with_no_authors_filter() { + // Owner doesn't necessarily know the agent's pubkey ahead of time. + let (_, owner, _) = three_pubkeys(); + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .custom_tag(p_tag, [&owner]); + assert!(engram_filters_authorized(&[f], &owner)); + } + + #[test] + fn engram_gate_rejects_unrelated_reader() { + let (agent, owner, attacker) = three_pubkeys(); + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + // Attacker tries to fish for engrams between agent and owner. + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .author(nostr::PublicKey::from_hex(&agent).unwrap()) + .custom_tag(p_tag, [&owner]); + assert!(!engram_filters_authorized(&[f], &attacker)); + } + + #[test] + fn engram_gate_rejects_bare_kind_filter() { + // {kinds:[30174]} with no authors and no #p — open fishing. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new().kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)); + assert!(!engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_rejects_wildcard_kind_filter() { + // Filter with no kinds field at all — matches everything including + // engrams; must still be gated. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new(); + assert!(!engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_skips_non_engram_kinds() { + // Filter not targeting engrams — pass through; this gate is silent. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new().kind(nostr::Kind::Custom(9)); + assert!(engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_allows_ids_lookup() { + // Specific event ids — knowing the id implies prior authorization. + let (agent, _, _) = three_pubkeys(); + let id = nostr::EventId::from_hex( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ) + .unwrap(); + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .id(id); + assert!(engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_rejects_mixed_authors_with_unauthed() { + // {authors:[self, attacker]} — must reject; an author-list with any + // non-self entry could let an attacker piggy-back on the agent's + // legitimate query path. + let (agent, other, _) = three_pubkeys(); + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .authors([ + nostr::PublicKey::from_hex(&agent).unwrap(), + nostr::PublicKey::from_hex(&other).unwrap(), + ]); + assert!(!engram_filters_authorized(&[f], &agent)); + } + + // ── NIP-50 search bypass regressions ───────────────────────────────── + // These filters are the shape an authenticated relay member would send + // to try to harvest indexed engram envelopes via the search path. The + // gate must reject them regardless of the presence of `search`. + + #[test] + fn engram_gate_rejects_bare_kind_search_filter() { + // {"search":"*", "kinds":[30174]} — exactly the bypass codex found. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .search("*"); + assert!(!engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_rejects_wildcard_kind_search_filter() { + // {"search":"foo"} — no `kinds` field at all matches engrams too. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new().search("foo"); + assert!(!engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn engram_gate_allows_authored_engram_search() { + // Agent searching their own engrams by content keyword is legitimate. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new() + .kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)) + .author(nostr::PublicKey::from_hex(&agent).unwrap()) + .search("foo"); + assert!(engram_filters_authorized(&[f], &agent)); + } + + #[test] + fn p_gate_rejects_bare_kind_search_filter_for_gift_wrap() { + // P-gated kinds (observer frames, member notifications) are indexed + // too. Same bypass shape: {"search":"x","kinds":[]}. + // Use KIND_AGENT_OBSERVER_FRAME — globally stored, p-gated, indexed. + let (agent, _, _) = three_pubkeys(); + let f = Filter::new() + .kind(nostr::Kind::Custom( + sprout_core::kind::KIND_AGENT_OBSERVER_FRAME as u16, + )) + .search("x"); + assert!(!p_gated_filters_authorized(&[f], &agent)); + } }