fix(cli): resolve agents from owner records (#3178)

## Context

`buzz users get --name Honey` searches relay-wide profiles and can
return an identically named agent owned by someone else. This caused
agents from the wrong owner to be added to a channel.

## Summary

This bug fix scopes exact-name agent lookup to owner-authored
managed-agent records, then cryptographically verifies each returned
profile's NIP-OA `auth` tag before asserting ownership. The relay and
database contracts remain unchanged.

### Related issue

None found.

## Changes

- Adds `buzz users get --name Honey --owner me|<hex>|<npub>`.
- Resolves `me` to the NIP-OA owner when the CLI runs as an agent,
otherwise to the CLI identity.
- Matches kind `30177` managed-agent record names exactly and
case-insensitively under the requested owner.
- Requires exactly one valid NIP-OA `auth` tag whose verified owner
equals the requested owner and whose `kind` and `created_at` conditions
apply to the profile event before returning `owner_pubkey` or
`owned_by_me: true`.
- Keeps missing, malformed, stale, condition-mismatched, or unverifiable
owner-record candidates visible with `owned_by_me: false` and an
explicit `verification` value.
- Returns every same-name record for the owner so callers can require
explicit selection when duplicates remain.
- Preserves the existing output shape and client-side name filter for
unscoped searches.
- Documents the distinct owner-scoped managed-agent lookup and unscoped
NIP-50 lookup modes.

### Testing

The reviewer-reproducible red and green commands below exercise the
ownership bug against the target branch and this branch.

## Screenshots

Not applicable. This is a CLI-only change.

## Reviewer-reproducible examples

The lookups below were run against the live relay from `main` and this
branch.

### Red: unscoped lookup returns the 100-profile relay-wide cap and
excludes John's agents

On `main`:

```bash
cargo run -q -p buzz-cli -- users get --name Honey \
  | jq '{count: length, first_three: .[:3] | map(.pubkey), johns_agents: map(select(.pubkey == "31b29bcbe69d6716fbb7ba33602b89200bfc9ddfdabcfd1ea6fbfa70b816dfc7" or .pubkey == "4597ac725bba33fc7dd0454c1e2316a5ed770426acf667837d46f6553b3fcf54"))}'
```

Observed output:

```json
{
  "count": 100,
  "first_three": [
    "20d27fc6c0ab4f50b66d1a32a64c5ca1fb985254143ce911f61ab7733333c3d7",
    "00644478cdd9032c563ddc712b3687d8345d948945aab3c18bab95afbf6f519a",
    "93c16697d0e58007bc11fb953208bc6b1cff387b2dee094abc10bf82dfee5424"
  ],
  "johns_agents": []
}
```

`main` also rejects the owner-scoped command:

```bash
cargo run -q -p buzz-cli -- users get --name Honey --owner me
```

```text
error: unexpected argument '--owner' found
Usage: buzz users get --name <NAME>
```

### Green: owner-scoped lookup distinguishes verified and unresolved
records

On this branch:

```bash
cargo run -q -p buzz-cli -- users get --name Honey --owner me \
  | jq 'map({pubkey,display_name,owner_pubkey,owned_by_me,verification})'
```

Observed output:

```json
[
  {
    "pubkey": "0ca77314d7ac8b3fcf6c647cc8cb9c3afd840db3b2a8ff2079f09a168de1827e",
    "display_name": null,
    "owner_pubkey": null,
    "owned_by_me": false,
    "verification": "missing_profile"
  },
  {
    "pubkey": "31b29bcbe69d6716fbb7ba33602b89200bfc9ddfdabcfd1ea6fbfa70b816dfc7",
    "display_name": "Honey",
    "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820",
    "owned_by_me": true,
    "verification": "verified"
  },
  {
    "pubkey": "4597ac725bba33fc7dd0454c1e2316a5ed770426acf667837d46f6553b3fcf54",
    "display_name": "Honey",
    "owner_pubkey": "67252b09c31a995daa63aada26569fbc6a3d12f573113f001ce7432f870da820",
    "owned_by_me": true,
    "verification": "verified"
  }
]
```

Only the two profiles with valid NIP-OA proofs assert ownership. The
owner-authored record whose profile is absent remains visible but cannot
be selected as verified ownership.

---------

Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
This commit is contained in:
John Matthew Tennant
2026-07-30 09:28:10 -04:00
committed by GitHub
co-authored by npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k
parent 3b8567a05d
commit 262f2392e3
5 changed files with 442 additions and 54 deletions
+1
View File
@@ -276,6 +276,7 @@ test-unit:
#!/usr/bin/env bash
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
cargo nextest run -p buzz-cli
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
# 0001; cutover/backfill stays an operator script, not startup state)
+1
View File
@@ -56,6 +56,7 @@ buzz reactions get --event <event-id>
buzz users get # your own profile
buzz users get --pubkey <hex> # single user
buzz users get --pubkey <hex> --pubkey <hex> # batch (max 200)
buzz users get --name Honey --owner me # exact-name lookup in your managed agents
buzz users set-presence --status online
buzz users set-status --text "heads down on the CLI" --emoji "🚀"
buzz users set-status --clear # remove your status
+434 -54
View File
@@ -1,4 +1,7 @@
use crate::client::{normalize_write_response, BuzzClient};
use buzz_core::kind::KIND_MANAGED_AGENT;
use nostr::PublicKey;
use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
@@ -13,6 +16,7 @@ pub async fn cmd_get_users(
client: &BuzzClient,
pubkeys: &[String],
name: Option<&str>,
owner: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if let Some(query) = name {
@@ -21,7 +25,11 @@ pub async fn cmd_get_users(
"--name and --pubkey are mutually exclusive".into(),
));
}
return search_by_name(client, query, format).await;
return search_by_name(client, query, owner, format).await;
}
if owner.is_some() {
return Err(CliError::Usage("--owner requires --name".into()));
}
for pk in pubkeys {
@@ -76,68 +84,269 @@ pub async fn cmd_get_users(
Ok(())
}
/// Search for users by display name via NIP-50 full-text search on kind:0 profiles.
/// Returns [] if the relay does not implement NIP-50 search.
fn effective_owner(client: &BuzzClient) -> String {
client
.auth_tag_owner_hex()
.unwrap_or_else(|| client.keys().public_key().to_hex())
}
fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result<Option<String>, CliError> {
owner
.map(|owner| {
if owner == "me" {
Ok(effective_owner(client))
} else {
PublicKey::parse(owner)
.map(|pubkey| pubkey.to_hex())
.map_err(|e| {
CliError::Usage(format!("--owner must be `me`, a pubkey, or npub: {e}"))
})
}
})
.transpose()
}
fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) -> Vec<String> {
let mut pubkeys: Vec<String> = events
.iter()
.filter_map(|event| {
let content: serde_json::Value =
serde_json::from_str(event.get("content")?.as_str()?).ok()?;
let name = content.get("name")?.as_str()?;
if !name.eq_ignore_ascii_case(query) {
return None;
}
let pubkey = extract_d_tag(event);
(!pubkey.is_empty()).then_some(pubkey)
})
.collect();
pubkeys.sort();
pubkeys.dedup();
pubkeys
}
async fn owned_agent_pubkeys_by_name(
client: &BuzzClient,
owner: &str,
query: &str,
) -> Result<Vec<String>, CliError> {
let filter = serde_json::json!({
"kinds": [KIND_MANAGED_AGENT],
"authors": [owner],
});
let events = client.query_all(filter).await?;
Ok(owned_agent_pubkeys_from_events(&events, query))
}
fn profile_content(event: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
event
.get("content")
.and_then(|value| value.as_str())
.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok())
.and_then(|content| content.as_object().cloned())
.unwrap_or_default()
}
fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec<serde_json::Value> {
let lower_query = query.to_ascii_lowercase();
events
.iter()
.filter_map(|event| {
let mut profile = profile_content(event);
let display_name = profile
.get("display_name")
.and_then(|value| value.as_str())
.unwrap_or("");
let name = profile
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
if !display_name.to_ascii_lowercase().contains(&lower_query)
&& !name.to_ascii_lowercase().contains(&lower_query)
{
return None;
}
profile.insert(
"pubkey".to_string(),
serde_json::json!(event
.get("pubkey")
.and_then(|value| value.as_str())
.unwrap_or("")),
);
Some(serde_json::Value::Object(profile))
})
.collect()
}
fn auth_tag_values(event: &serde_json::Value) -> Vec<&serde_json::Value> {
event
.get("tags")
.and_then(|tags| tags.as_array())
.into_iter()
.flatten()
.filter(|tag| {
tag.as_array()
.and_then(|values| values.first())
.and_then(|value| value.as_str())
== Some("auth")
})
.collect()
}
fn auth_conditions_apply(auth_tag: &serde_json::Value, event: &serde_json::Value) -> bool {
let Some(conditions) = auth_tag
.as_array()
.and_then(|values| values.get(2))
.and_then(|value| value.as_str())
else {
return false;
};
let Some(kind) = event.get("kind").and_then(|value| value.as_u64()) else {
return false;
};
let Some(created_at) = event.get("created_at").and_then(|value| value.as_u64()) else {
return false;
};
conditions.split('&').all(|clause| {
if let Some(value) = clause.strip_prefix("kind=") {
value.parse::<u64>() == Ok(kind)
} else if let Some(value) = clause.strip_prefix("created_at<") {
value.parse::<u64>().is_ok_and(|bound| created_at < bound)
} else if let Some(value) = clause.strip_prefix("created_at>") {
value.parse::<u64>().is_ok_and(|bound| created_at > bound)
} else {
clause.is_empty()
}
})
}
fn owner_verification(event: &serde_json::Value, expected_owner: &str) -> &'static str {
let Some(agent_pubkey) = event
.get("pubkey")
.and_then(|value| value.as_str())
.and_then(|value| PublicKey::parse(value).ok())
else {
return "invalid_agent_pubkey";
};
let auth_tags = auth_tag_values(event);
let [auth_tag] = auth_tags.as_slice() else {
return if auth_tags.is_empty() {
"missing_auth"
} else {
"multiple_auth_tags"
};
};
let Ok(auth_tag_json) = serde_json::to_string(auth_tag) else {
return "invalid_auth";
};
match buzz_sdk::nip_oa::verify_auth_tag(&auth_tag_json, &agent_pubkey) {
Ok(owner) if owner.to_hex() != expected_owner => "owner_mismatch",
Ok(_) if !auth_conditions_apply(auth_tag, event) => "condition_mismatch",
Ok(_) => "verified",
Err(_) => "invalid_auth",
}
}
fn owner_scoped_profiles(
events: &[serde_json::Value],
pubkeys: &[String],
owner: &str,
effective_owner: &str,
) -> Vec<serde_json::Value> {
pubkeys
.iter()
.map(|pubkey| {
let event = events.iter().find(|event| {
event.get("pubkey").and_then(|value| value.as_str()) == Some(pubkey.as_str())
});
let mut profile = event.map(profile_content).unwrap_or_default();
let verification = if PublicKey::parse(pubkey).is_err() {
"invalid_agent_pubkey"
} else {
event
.map(|event| owner_verification(event, owner))
.unwrap_or("missing_profile")
};
profile.insert("pubkey".to_string(), serde_json::json!(pubkey));
profile.insert("verification".to_string(), serde_json::json!(verification));
profile.insert(
"owned_by_me".to_string(),
serde_json::json!(verification == "verified" && owner == effective_owner),
);
if verification == "verified" {
profile.insert("owner_pubkey".to_string(), serde_json::json!(owner));
}
serde_json::Value::Object(profile)
})
.collect()
}
/// Search for users by display name. Owner-scoped searches resolve managed-agent records
/// and verify their profiles; unscoped searches use NIP-50 and return [] if unsupported.
async fn search_by_name(
client: &BuzzClient,
query: &str,
owner: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if query.trim().is_empty() {
return Err(CliError::Usage("--name cannot be empty".into()));
}
let filter = serde_json::json!({
"kinds": [0],
"search": query,
"limit": 100
});
let raw = client.query(&filter).await?;
// Parse and filter client-side for case-insensitive substring match
// on display_name or name fields (NIP-50 may return broader matches).
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
let Some(arr) = events.as_array() else {
println!("[]");
return Ok(());
let owner = resolve_owner(client, owner)?;
let profiles = if let Some(owner) = owner {
let pubkeys = owned_agent_pubkeys_by_name(client, &owner, query).await?;
if pubkeys.is_empty() {
println!("[]");
return Ok(());
}
let valid_pubkeys: Vec<&String> = pubkeys
.iter()
.filter(|pubkey| PublicKey::parse(pubkey.as_str()).is_ok())
.collect();
let events = if valid_pubkeys.is_empty() {
Vec::new()
} else {
let filter = serde_json::json!({
"kinds": [0],
"authors": valid_pubkeys,
"limit": valid_pubkeys.len(),
});
let raw = client.query(&filter).await?;
serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?
};
owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client))
} else {
let filter = serde_json::json!({
"kinds": [0],
"search": query,
"limit": 100
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
name_search_profiles(&events, query)
};
let lower_query = query.to_ascii_lowercase();
let profiles: Vec<serde_json::Value> = arr
.iter()
.filter_map(|event| {
let content_str = event.get("content").and_then(|v| v.as_str())?;
let content: serde_json::Value = serde_json::from_str(content_str).ok()?;
let display_name = content
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let name = content.get("name").and_then(|v| v.as_str()).unwrap_or("");
if !display_name.to_ascii_lowercase().contains(&lower_query)
&& !name.to_ascii_lowercase().contains(&lower_query)
{
return None;
}
let mut profile = content;
if let Some(obj) = profile.as_object_mut() {
obj.insert(
"pubkey".to_string(),
serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
);
}
Some(profile)
})
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
}))
.map(|p| {
let mut value = serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
});
if let Some(obj) = value.as_object_mut() {
for field in ["owner_pubkey", "owned_by_me", "verification"] {
if let Some(field_value) = p.get(field) {
obj.insert(field.to_string(), field_value.clone());
}
}
}
value
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
@@ -327,9 +536,11 @@ pub async fn dispatch(
) -> Result<(), CliError> {
use crate::UsersCmd;
match cmd {
UsersCmd::Get { pubkeys, name } => {
cmd_get_users(client, &pubkeys, name.as_deref(), format).await
}
UsersCmd::Get {
pubkeys,
name,
owner,
} => cmd_get_users(client, &pubkeys, name.as_deref(), owner.as_deref(), format).await,
UsersCmd::SetProfile {
name,
avatar,
@@ -362,9 +573,178 @@ pub async fn dispatch(
#[cfg(test)]
mod tests {
use super::presence_subject;
use super::{
owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification,
presence_subject,
};
use nostr::Keys;
use serde_json::json;
#[test]
fn owned_agent_lookup_matches_exact_name_case_insensitively() {
let events = vec![
json!({"content": r#"{"name":"Honey"}"#, "tags": [["d", "b"]]}),
json!({"content": r#"{"name":"Honeybee"}"#, "tags": [["d", "c"]]}),
json!({"content": r#"{"name":"honey"}"#, "tags": [["d", "a"]]}),
];
assert_eq!(
owned_agent_pubkeys_from_events(&events, "Honey"),
vec!["a", "b"]
);
}
#[test]
fn owned_agent_lookup_ignores_malformed_events() {
let events = vec![
json!({"content": "not json", "tags": [["d", "a"]]}),
json!({"content": r#"{"name":"Honey"}"#, "tags": [["p", "b"]]}),
];
assert!(owned_agent_pubkeys_from_events(&events, "Honey").is_empty());
}
fn profile_event(agent_keys: &Keys, auth_tags: Vec<serde_json::Value>) -> serde_json::Value {
json!({
"pubkey": agent_keys.public_key().to_hex(),
"kind": 0,
"created_at": 100,
"content": r#"{"display_name":"Renamed Honey"}"#,
"tags": auth_tags,
})
}
#[test]
fn owner_verification_requires_one_valid_auth_tag_for_requested_owner() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let foreign_owner_keys = Keys::generate();
let valid_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0")
.unwrap(),
)
.unwrap();
let foreign_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(
&foreign_owner_keys,
&agent_keys.public_key(),
"kind=9",
)
.unwrap(),
)
.unwrap();
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![valid_tag.clone()]),
&owner_keys.public_key().to_hex(),
),
"verified"
);
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![foreign_tag]),
&owner_keys.public_key().to_hex(),
),
"owner_mismatch"
);
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![]),
&owner_keys.public_key().to_hex()
),
"missing_auth"
);
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![valid_tag.clone(), valid_tag]),
&owner_keys.public_key().to_hex(),
),
"multiple_auth_tags"
);
assert_eq!(
owner_verification(
&profile_event(
&agent_keys,
vec![json!([
"auth",
owner_keys.public_key().to_hex(),
"kind=9",
"0".repeat(128)
])],
),
&owner_keys.public_key().to_hex(),
),
"invalid_auth"
);
}
#[test]
fn owner_verification_requires_conditions_to_apply_to_profile_event() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let verification = |conditions: &str| {
let auth_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(
&owner_keys,
&agent_keys.public_key(),
conditions,
)
.unwrap(),
)
.unwrap();
owner_verification(
&profile_event(&agent_keys, vec![auth_tag]),
&owner_keys.public_key().to_hex(),
)
};
assert_eq!(verification("kind=9"), "condition_mismatch");
assert_eq!(verification("created_at<100"), "condition_mismatch");
assert_eq!(verification("created_at>100"), "condition_mismatch");
assert_eq!(
verification("kind=0&created_at>99&created_at<101"),
"verified"
);
}
#[test]
fn owner_scoped_profiles_keep_drifted_and_missing_profiles_without_claiming_ownership() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let missing_keys = Keys::generate();
let auth_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0")
.unwrap(),
)
.unwrap();
let events = vec![profile_event(&agent_keys, vec![auth_tag])];
let pubkeys = vec![
agent_keys.public_key().to_hex(),
missing_keys.public_key().to_hex(),
"malformed".to_string(),
];
let profiles = owner_scoped_profiles(
&events,
&pubkeys,
&owner_keys.public_key().to_hex(),
&owner_keys.public_key().to_hex(),
);
assert_eq!(profiles[0]["display_name"], "Renamed Honey");
assert_eq!(profiles[0]["verification"], "verified");
assert_eq!(profiles[0]["owned_by_me"], true);
assert_eq!(
profiles[0]["owner_pubkey"],
owner_keys.public_key().to_hex()
);
assert_eq!(profiles[1]["verification"], "missing_profile");
assert_eq!(profiles[1]["owned_by_me"], false);
assert!(profiles[1].get("owner_pubkey").is_none());
assert_eq!(profiles[2]["verification"], "invalid_agent_pubkey");
assert_eq!(profiles[2]["owned_by_me"], false);
assert!(profiles[2].get("owner_pubkey").is_none());
}
#[test]
fn presence_subject_uses_p_tag() {
let event = json!({"pubkey": "relay", "tags": [["p", "user"]]});
+3
View File
@@ -811,6 +811,9 @@ pub enum UsersCmd {
/// Search by display name (case-insensitive substring match)
#[arg(long = "name")]
name: Option<String>,
/// Scope an exact-name agent lookup to its owner (`me`, hex, or npub)
#[arg(long = "owner", requires = "name")]
owner: Option<String>,
},
/// Update the current identity's profile
#[command(name = "set-profile")]
+3
View File
@@ -84,6 +84,9 @@ run_unit_tests() {
run_test_step "buzz-auth unit tests" \
cargo test -p buzz-auth --lib -- --nocapture
run_test_step "buzz-cli tests" \
cargo test -p buzz-cli -- --nocapture
# buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator
# invariant (exactly the consolidated 0001; cutover/backfill stays an operator
# script, not startup state) and the tenant-scoping lints. The Postgres-backed