mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
248
crates/sprout-acp/src/engram_fetch.rs
Normal file
248
crates/sprout-acp/src/engram_fetch.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
364
crates/sprout-cli/src/commands/mem.rs
Normal file
364
crates/sprout-cli/src/commands/mem.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
835
crates/sprout-core/src/engram.rs
Normal file
835
crates/sprout-core/src/engram.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user