NIP-AE: agent engrams (kind:30174) — core memory injection + sprout mem CLI (#593)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-19 13:16:37 -04:00
committed by GitHub
parent 3973080dce
commit 24520aa2b1
21 changed files with 2478 additions and 69 deletions

3
Cargo.lock generated
View File

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

View File

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

View File

@@ -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<String>,
/// 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]

View File

@@ -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<profile>`.
//! - 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<String> {
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<Option<String>, 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<Option<String>, 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<String> = 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<Event> = 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:?}");
}
}

View File

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

View File

@@ -82,6 +82,9 @@ pub struct SessionState {
pub turn_counts: HashMap<Uuid, u32>,
/// 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<Uuid, String>,
}
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<nostr::PublicKey>,
/// 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(),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,364 @@
//! `sprout mem` — agent-side engram management (NIP-AE).
//!
//! Subcommands:
//! - `sprout mem ls` — list non-tombstoned memories
//! - `sprout mem get <slug>` — print the value to stdout
//! - `sprout mem set <slug> <value|-> ` — write a value (use `-` for stdin)
//! - `sprout mem rm <slug>` — 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<PublicKey, CliError> {
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<Vec<nostr::Event>, 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::<nostr::Event>(ev.clone()) {
out.push(event);
}
}
Ok(out)
}
/// Fetch the head event for `slug`, returning `(Option<Event>, Option<Body>)`.
async fn fetch_head(
client: &SproutClient,
owner: &PublicKey,
slug: &str,
) -> Result<(Option<nostr::Event>, Option<Body>), 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<nostr::Event> = 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<String, Vec<(nostr::Event, Body)>> = 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<Listing> = Vec::new();
for (_d, members) in groups {
let events: Vec<nostr::Event> = 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 <slug>` — 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 <slug> <value|->` — 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 <slug>` — 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,
}
}

View File

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

View File

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

View File

@@ -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\": \"<category>\", \"message\": \"<detail>\"}"
)]
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<String>,
/// 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<String>,
},
/// Set a slug's value. Pass `-` to read the value from stdin.
Set {
slug: String,
value: String,
#[arg(long)]
owner: Option<String>,
},
/// Publish a tombstone for a slug (cannot be used on `core`).
Rm {
slug: String,
#[arg(long)]
owner: Option<String>,
},
}
// ---------------------------------------------------------------------------
// 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",

View File

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

View File

@@ -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<String, EngramError> {
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 = <Hmac<Sha256> 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<String>,
},
}
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<u8> {
// 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<Self, EngramError> {
// 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<serde_json::Value, EngramError> {
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<D: Deserializer<'de>>(self, d: D) -> Result<Value, D::Error> {
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<E>(self, v: bool) -> Result<Value, E> {
Ok(Value::Bool(v))
}
fn visit_i64<E>(self, v: i64) -> Result<Value, E> {
Ok(Value::Number(v.into()))
}
fn visit_u64<E>(self, v: u64) -> Result<Value, E> {
Ok(Value::Number(v.into()))
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
serde_json::Number::from_f64(v)
.map(Value::Number)
.ok_or_else(|| E::custom("non-finite float"))
}
fn visit_str<E>(self, v: &str) -> Result<Value, E> {
Ok(Value::String(v.to_owned()))
}
fn visit_string<E>(self, v: String) -> Result<Value, E> {
Ok(Value::String(v))
}
fn visit_unit<E>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Value, D::Error> {
d.deserialize_any(StrictValue)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Value, A::Error> {
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<A: MapAccess<'de>>(self, mut map: A) -> Result<Value, A::Error> {
use serde::de::Error;
let mut seen: HashSet<String> = HashSet::new();
let mut out = serde_json::Map::new();
while let Some(k) = map.next_key::<String>()? {
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<Event, EngramError> {
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<Body, EngramError> {
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<I>(events: I) -> Option<Event>
where
I: IntoIterator<Item = Event>,
{
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>) -> 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(_)));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Scope, &'static s
match kind {
KIND_PROFILE => 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<u8> {
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<Uuid> = 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}");
}
}

View File

@@ -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":[<p-gated kind>]}.
// 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));
}
}