From b88a63f18ca8cd18c84fef948a5d7142ba4885ea Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 14:07:08 -0400 Subject: [PATCH] fix(policy): refuse a write naming more people than are checked A write naming more accounts than MAX_NAMED_ACCOUNTS, while a policy declaring a lookup applies, is refused before it is judged rather than judged on the first few. The AI-preference refusal calls a person by the handle the record's own mention used, falling back to the DID. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I5e2b4092eee7e71bcb8c0513d78d24708c4733fc --- crates/didbot-lookup/src/lib.rs | 15 ++- crates/didbot-pds/src/policy.rs | 16 ++- crates/didbot-pds/src/provision/lifecycle.rs | 9 +- crates/didbot-pds/src/provision/registry.rs | 2 +- crates/didbot-pds/src/provision/write.rs | 4 +- crates/didbot-policy-cedar/src/shared.rs | 4 + .../floor/declared-ai-preference.cases.json | 6 +- .../floor/declared-ai-preference.json | 4 +- crates/didbot-policy-records/src/lib.rs | 2 +- crates/didbot-policy-records/src/named.rs | 109 ++++++++++++++++++ .../tests/floor_cases.rs | 58 +++++++--- crates/didbot-policy-regex/src/lib.rs | 15 ++- crates/didbot-policy/src/subject.rs | 4 + crates/didbot-serve/src/lookups.rs | 51 ++++++-- crates/didbot-serve/tests/ai_preference.rs | 84 ++++++++++++++ docs/trust-model.md | 6 +- docs/write-pipeline.md | 5 + 17 files changed, 350 insertions(+), 44 deletions(-) diff --git a/crates/didbot-lookup/src/lib.rs b/crates/didbot-lookup/src/lib.rs index 60059242..334f7d81 100644 --- a/crates/didbot-lookup/src/lib.rs +++ b/crates/didbot-lookup/src/lib.rs @@ -168,10 +168,21 @@ pub const DEFAULT_PREFETCH_DEADLINE: Duration = Duration::from_millis(4000); /// is already several repositories. Sixteen covers that post, and the reply /// and quote gates a thread rule wants, with room over. /// -/// A write naming more accounts than this reads the first of them and -/// leaves the rest unknown, which each statement decides for itself. +/// A write needing more records than this is refused before it is judged, +/// rather than judged on some of them; see [`MAX_NAMED_ACCOUNTS`]. pub const DEFAULT_PER_WRITE: usize = 16; +/// The most accounts one write may name while a policy that reads something +/// about each of them applies to it. +/// +/// **Reasonable range: 1 to 64.** A write naming more is refused outright: +/// judging it on the first few would let a record built to name one more +/// person than this server checks name anybody at all. The count is of +/// accounts this deployment does not hold, since its own are read from its +/// own store. It is also held to [`Limits::per_write`] divided by the +/// lookups that apply, so every account named is read. +pub const MAX_NAMED_ACCOUNTS: usize = 16; + /// What a [`ForeignRecords`] is bounded by. Every field is a parameter a /// deployment sets on its own, with its default and its reasonable range on /// the constant it comes from. diff --git a/crates/didbot-pds/src/policy.rs b/crates/didbot-pds/src/policy.rs index c4c9a3d7..c7395d03 100644 --- a/crates/didbot-pds/src/policy.rs +++ b/crates/didbot-pds/src/policy.rs @@ -237,6 +237,9 @@ pub struct OwnedLookup { pub name: String, /// The DID of the repository that was read. pub account: String, + /// The handle the written record calls that account by, if it carries + /// one. See [`didbot_policy::Lookup::handle`]. + pub handle: Option, /// The at-uri that was read. pub uri: String, /// What was there. @@ -262,6 +265,7 @@ impl OwnedLookup { didbot_policy::Lookup { name: &self.name, account: &self.account, + handle: self.handle.as_deref(), uri: &self.uri, found: match &self.found { OwnedFound::Records(records) => didbot_policy::Found::Records(records), @@ -298,9 +302,15 @@ pub trait WriteLookups: Send + Sync { account: &str, collection: &str, after: Option<&serde_json::Value>, - ) -> Vec; + ) -> Result, LookupRefusal>; } +/// A write the reader refuses before any policy judges it, because the +/// policies that apply could not be asked about all of it. Its text is +/// what the writer reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LookupRefusal(pub String); + /// The lookups of a deployment that reads none: no policy declared one, or /// none is configured. An empty answer denies nothing, the same way an /// empty policy set does. @@ -312,8 +322,8 @@ impl WriteLookups for NoLookups { _account: &str, _collection: &str, _after: Option<&serde_json::Value>, - ) -> Vec { - Vec::new() + ) -> Result, LookupRefusal> { + Ok(Vec::new()) } } diff --git a/crates/didbot-pds/src/provision/lifecycle.rs b/crates/didbot-pds/src/provision/lifecycle.rs index b449bdd8..4a24f002 100644 --- a/crates/didbot-pds/src/provision/lifecycle.rs +++ b/crates/didbot-pds/src/provision/lifecycle.rs @@ -240,13 +240,18 @@ where account: &str, collection: &str, after: Option<&serde_json::Value>, - ) -> Vec { + ) -> Result, ProvisionError> { let lookups = self .lookups .read() .unwrap_or_else(|p| p.into_inner()) .clone(); - lookups.answers(account, collection, after) + lookups + .answers(account, collection, after) + .map_err(|crate::policy::LookupRefusal(reason)| { + tracing::info!(%account, %collection, %reason, "write refused before its policies were asked"); + ProvisionError::PolicyRejected { reason } + }) } pub(super) fn freeze_syncing_gate(&self, account: &HostedAccount) -> FreezeSyncingGate<'_, S> { diff --git a/crates/didbot-pds/src/provision/registry.rs b/crates/didbot-pds/src/provision/registry.rs index 9ba98762..88fb81c6 100644 --- a/crates/didbot-pds/src/provision/registry.rs +++ b/crates/didbot-pds/src/provision/registry.rs @@ -1020,7 +1020,7 @@ where }; self.read_lookups(account.did.as_str(), op.collection(), after) }) - .collect(); + .collect::>()?; let lookups: Vec<_> = answers .iter() .map(|answers| crate::policy::borrow_lookups(answers)) diff --git a/crates/didbot-pds/src/provision/write.rs b/crates/didbot-pds/src/provision/write.rs index d8cf338c..8f3e6755 100644 --- a/crates/didbot-pds/src/provision/write.rs +++ b/crates/didbot-pds/src/provision/write.rs @@ -306,7 +306,7 @@ where let changes = policy_diff(Some(old), None); let changes = crate::policy::borrow(&changes); let pds_did = self.zone.service_did(); - let answers = self.read_lookups(account.did.as_str(), collection, None); + let answers = self.read_lookups(account.did.as_str(), collection, None)?; let lookups = crate::policy::borrow_lookups(&answers); let subject = crate::policy::write_subject( account.did.as_str(), @@ -508,7 +508,7 @@ where }; let changes = crate::policy::borrow(&changes); let pds_did = self.zone.service_did(); - let answers = self.read_lookups(account.did.as_str(), collection, Some(&record)); + let answers = self.read_lookups(account.did.as_str(), collection, Some(&record))?; let lookups = crate::policy::borrow_lookups(&answers); let subject = crate::policy::write_subject( account.did.as_str(), diff --git a/crates/didbot-policy-cedar/src/shared.rs b/crates/didbot-policy-cedar/src/shared.rs index 5a6a103b..6f003181 100644 --- a/crates/didbot-policy-cedar/src/shared.rs +++ b/crates/didbot-policy-cedar/src/shared.rs @@ -344,6 +344,7 @@ struct OwnedChange { struct OwnedLookup { name: String, account: String, + handle: Option, uri: String, found: OwnedFound, } @@ -398,11 +399,13 @@ impl Owned { |&Lookup { name, account, + handle, uri, found, }| OwnedLookup { name: name.to_owned(), account: account.to_owned(), + handle: handle.map(str::to_owned), uri: uri.to_owned(), found: match found { Found::Records(records) => OwnedFound::Records(records.clone()), @@ -483,6 +486,7 @@ impl Owned { .map(|lookup| Lookup { name: &lookup.name, account: &lookup.account, + handle: lookup.handle.as_deref(), uri: &lookup.uri, found: match &lookup.found { OwnedFound::Records(records) => Found::Records(records), diff --git a/crates/didbot-policy-records/floor/declared-ai-preference.cases.json b/crates/didbot-policy-records/floor/declared-ai-preference.cases.json index d362da6d..c5cf851d 100644 --- a/crates/didbot-policy-records/floor/declared-ai-preference.cases.json +++ b/crates/didbot-policy-records/floor/declared-ai-preference.cases.json @@ -36,6 +36,8 @@ { "name": "a mention of someone who declined", "expect": "deny", + "denies": "did:web:wren.example", + "called": "wren.bsky.social", "repositories": { "did:web:wren.example": { "self": { @@ -52,11 +54,11 @@ "collection": "app.bsky.feed.post", "after": { "$type": "app.bsky.feed.post", - "text": "@wren.example", + "text": "@wren.bsky.social", "createdAt": "2026-09-22T00:00:00.000Z", "facets": [ { - "index": { "byteStart": 0, "byteEnd": 13 }, + "index": { "byteStart": 0, "byteEnd": 17 }, "features": [ { "$type": "app.bsky.richtext.facet#mention", "did": "did:web:wren.example" } ] diff --git a/crates/didbot-policy-records/floor/declared-ai-preference.json b/crates/didbot-policy-records/floor/declared-ai-preference.json index 8a2f6408..61132045 100644 --- a/crates/didbot-policy-records/floor/declared-ai-preference.json +++ b/crates/didbot-policy-records/floor/declared-ai-preference.json @@ -37,8 +37,8 @@ "path": "preferences.syntheticContent.allow", "denied": [{ "regex": "^false$" }], "unknown": "standAside", - "reason": "${lookup.account} has set syntheticContent to false in their community.lexicon.preference.ai record, at ${select}, so this record was not written. Write nothing that names ${lookup.account}; a record naming somebody else, or nobody, is not refused by this rule. The record read is ${lookup.uri}.", - "unknownReason": "This deployment could not read ${lookup.account}'s community.lexicon.preference.ai record: ${lookup.unknown}. Its policy refuses a write naming somebody whose preference it has not read. The record it tried is ${lookup.uri}." + "reason": "${lookup.handleOrDid} has set syntheticContent to false in their community.lexicon.preference.ai record, at ${select}, so this record was not written. Write nothing that names ${lookup.handleOrDid}; a record naming somebody else, or nobody, is not refused by this rule. The record read is ${lookup.uri}.", + "unknownReason": "This deployment could not read ${lookup.handleOrDid}'s community.lexicon.preference.ai record: ${lookup.unknown}. Its policy refuses a write naming somebody whose preference it has not read. The record it tried is ${lookup.uri}." } ] }, diff --git a/crates/didbot-policy-records/src/lib.rs b/crates/didbot-policy-records/src/lib.rs index 9bd33047..d5b51a02 100644 --- a/crates/didbot-policy-records/src/lib.rs +++ b/crates/didbot-policy-records/src/lib.rs @@ -64,7 +64,7 @@ pub use compile::{CompileReport, Language, PolicyWarning, ScopedDenial}; pub use last_good::LastGoodPolicies; pub use lookups::Lookups; pub use merge::{build, LastGoodFallback, LoadReport}; -pub use named::named_accounts; +pub use named::{named_accounts, named_handles}; pub use record::ParsedPolicy; pub use records::{ Accounts, Action, Document, Includes, Lookup, PolicyBinding, PolicyRecord, PolicyRef, diff --git a/crates/didbot-policy-records/src/named.rs b/crates/didbot-policy-records/src/named.rs index 64f30b8f..9a6f1c7a 100644 --- a/crates/didbot-policy-records/src/named.rs +++ b/crates/didbot-policy-records/src/named.rs @@ -78,6 +78,88 @@ fn walk(value: &Value, depth: usize, found: &mut Vec) { } } +/// The handle `record` itself calls each account it mentions by, as +/// `(did, handle)` pairs in the order they are first met. +/// +/// A mention is a facet: an object holding a `text` string and a `facets` +/// list, each facet a byte range of that text and features naming a DID. +/// The span a feature's facet covers, less its leading `@`, is the handle +/// the writer used for that DID, when it is one. The writer chose it and +/// nothing checks that the DID answers to it, so it is only ever used to +/// tell the writer who their own record named. Like [`named_accounts`], +/// this reads the shape and never `$type`. +#[must_use] +pub fn named_handles(record: &Value) -> Vec<(String, String)> { + let mut found = Vec::new(); + walk_handles(record, 0, &mut found); + found +} + +fn walk_handles(value: &Value, depth: usize, found: &mut Vec<(String, String)>) { + if depth > MAX_DEPTH { + return; + } + match value { + Value::Array(items) => { + for item in items { + walk_handles(item, depth + 1, found); + } + } + Value::Object(fields) => { + if let (Some(Value::String(text)), Some(Value::Array(facets))) = + (fields.get("text"), fields.get("facets")) + { + for facet in facets { + mentioned(text, facet, found); + } + } + for (name, field) in fields { + if name != "$type" { + walk_handles(field, depth + 1, found); + } + } + } + _ => {} + } +} + +/// The `(did, handle)` pairs one facet over `text` carries. +fn mentioned(text: &str, facet: &Value, found: &mut Vec<(String, String)>) { + let index = facet.get("index"); + let start = index + .and_then(|i| i.get("byteStart")) + .and_then(Value::as_u64); + let end = index.and_then(|i| i.get("byteEnd")).and_then(Value::as_u64); + let (Some(start), Some(end)) = (start, end) else { + return; + }; + let (Ok(start), Ok(end)) = (usize::try_from(start), usize::try_from(end)) else { + return; + }; + let Some(span) = text.get(start..end) else { + return; + }; + let Some(handle) = span.strip_prefix('@') else { + return; + }; + if didbot_identity::validate_handle(handle).is_err() { + return; + } + let Some(Value::Array(features)) = facet.get("features") else { + return; + }; + for feature in features { + let Some(did) = feature.get("did").and_then(Value::as_str) else { + continue; + }; + if didbot_identity::validate_did(did).is_err() || found.iter().any(|(held, _)| held == did) + { + continue; + } + found.push((did.to_owned(), handle.to_owned())); + } +} + /// The DID a string is, or the DID an `at://` URI's authority is. fn did_in(text: &str) -> Option<&str> { let candidate = match text.strip_prefix("at://") { @@ -94,6 +176,33 @@ mod tests { use super::*; use serde_json::json; + /// A mention's handle is the text its facet spans, and only when that + /// text is `@` and a handle: a link facet over a URL names nobody. + #[test] + fn a_mention_names_its_account_by_the_handle_its_text_spans() { + let post = json!({ + "text": "hi @wren.example, see https://x.example", + "facets": [ + { + "index": { "byteStart": 3, "byteEnd": 16 }, + "features": [{ "$type": "app.bsky.richtext.facet#mention", "did": "did:web:wren.example" }], + }, + { + "index": { "byteStart": 22, "byteEnd": 39 }, + "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://x.example" }], + }, + { + "index": { "byteStart": 0, "byteEnd": 2 }, + "features": [{ "did": "did:web:not-a-handle.example" }], + }, + ], + }); + assert_eq!( + named_handles(&post), + [("did:web:wren.example".to_owned(), "wren.example".to_owned())] + ); + } + /// One post can name people four ways at once, and each way is a DID /// this has to find: a reply's two parents, a quote, and a mention. #[test] diff --git a/crates/didbot-policy-records/tests/floor_cases.rs b/crates/didbot-policy-records/tests/floor_cases.rs index bd9c5ef8..9b0de236 100644 --- a/crates/didbot-policy-records/tests/floor_cases.rs +++ b/crates/didbot-policy-records/tests/floor_cases.rs @@ -15,7 +15,7 @@ use didbot_policy::{ SubjectKind, Universal, WriteAction, }; use didbot_policy_records::records::{Accounts, PolicyRecord}; -use didbot_policy_records::{builtin_policies, named_accounts, Lookups}; +use didbot_policy_records::{builtin_policies, named_accounts, named_handles, Lookups}; use serde_json::{json, Value}; /// The deployment the cases are judged against: its hostname is the one a @@ -31,6 +31,8 @@ struct Case { expect: String, /// The account a denial must name, when the case says which. denies: Option, + /// The handle a denial must call them by, when the write spells one. + called: Option, /// Each account's whole `community.lexicon.preference.ai` collection, /// keyed by record key. An account named here and absent from the write /// costs nothing; one named by the write and absent here is a @@ -53,6 +55,7 @@ fn cases(source: &str) -> Vec { .expect("a case says what it expects") .to_owned(), denies: case["denies"].as_str().map(str::to_owned), + called: case["called"].as_str().map(str::to_owned), repositories: serde_json::from_value(case["repositories"].clone()).unwrap_or_default(), unreadable: serde_json::from_value(case["unreadable"].clone()).unwrap_or_default(), collection: case["write"]["collection"] @@ -68,12 +71,23 @@ fn cases(source: &str) -> Vec { /// judgment: the accounts the record names, crossed with the lookups the /// policy declared. struct Answers { - held: Vec<(String, String, String, Option)>, + held: Vec, +} + +/// One answer: the lookup's name, whose repository, what the write calls +/// them, the uri read, and the records found there. +struct Held { + name: String, + account: String, + handle: Option, + uri: String, + records: Option, } impl Answers { fn read(case: &Case, lookups: &Lookups) -> Self { let mut held = Vec::new(); + let handles = named_handles(&case.after); for lookup in lookups.for_write(&case.collection) { assert_eq!( lookup.accounts, @@ -101,16 +115,21 @@ impl Answers { Some(Value::Array(records)) if records.is_empty() => None, other => other, }; - held.push(( - lookup.name.clone(), - account.clone(), - uri, - if case.unreadable.contains(&account) { + let handle = handles + .iter() + .find(|(did, _)| *did == account) + .map(|(_, handle)| handle.clone()); + held.push(Held { + name: lookup.name.clone(), + records: if case.unreadable.contains(&account) { None } else { records }, - )); + account, + handle, + uri, + }); } } Self { held } @@ -119,13 +138,16 @@ impl Answers { fn borrow<'a>(&'a self, case: &Case) -> Vec> { self.held .iter() - .map(|(name, account, uri, records)| Lookup { - name, - account, - uri, - found: match records { + .map(|held| Lookup { + name: &held.name, + account: &held.account, + handle: held.handle.as_deref(), + uri: &held.uri, + found: match &held.records { Some(records) => Found::Records(records), - None if case.unreadable.contains(account) => Found::Unknown("unreachable"), + None if case.unreadable.contains(&held.account) => { + Found::Unknown("unreachable") + } None => Found::Nothing, }, }) @@ -218,6 +240,13 @@ fn the_declared_ai_preference_rule_answers_every_case() { "{}: the refusal has an unfilled name in it -- {reason}", case.name ); + if let Some(called) = &case.called { + assert!( + reason.starts_with(called.as_str()), + "{}: the refusal calls them by the handle the write used -- {reason}", + case.name + ); + } if let Some(declined) = &case.denies { assert!( reason.contains(declined), @@ -308,6 +337,7 @@ fn an_operator_narrows_the_floor_and_cannot_lift_it() { name: "their repository could not be read".to_owned(), expect: "deny".to_owned(), denies: None, + called: None, repositories: BTreeMap::new(), unreadable: vec!["did:web:wren.example".to_owned()], collection: "app.bsky.graph.follow".to_owned(), diff --git a/crates/didbot-policy-regex/src/lib.rs b/crates/didbot-policy-regex/src/lib.rs index bdf0c376..2c0d6bbd 100644 --- a/crates/didbot-policy-regex/src/lib.rs +++ b/crates/didbot-policy-regex/src/lib.rs @@ -661,6 +661,9 @@ enum Token { Collection, /// The account whose repository the lookup read. LookupAccount, + /// That account's handle, as the written record spells it, or its DID + /// when the record carries none. + LookupHandleOrDid, /// The at-uri of the record the statement decided from. LookupUri, /// The short word for why a lookup established nothing. @@ -670,7 +673,7 @@ enum Token { } impl Token { - const ALL: [Self; 10] = [ + const ALL: [Self; 11] = [ Self::SubjectDid, Self::SubjectHandle, Self::DeploymentDid, @@ -678,6 +681,7 @@ impl Token { Self::DeploymentOperator, Self::Collection, Self::LookupAccount, + Self::LookupHandleOrDid, Self::LookupUri, Self::LookupUnknown, Self::Select, @@ -692,6 +696,7 @@ impl Token { Self::DeploymentOperator => "deployment.operator", Self::Collection => "collection", Self::LookupAccount => "lookup.account", + Self::LookupHandleOrDid => "lookup.handleOrDid", Self::LookupUri => "lookup.uri", Self::LookupUnknown => "lookup.unknown", Self::Select => "select", @@ -707,7 +712,11 @@ impl Token { fn selects(self) -> bool { !matches!( self, - Self::LookupAccount | Self::LookupUri | Self::LookupUnknown | Self::Select + Self::LookupAccount + | Self::LookupHandleOrDid + | Self::LookupUri + | Self::LookupUnknown + | Self::Select ) } @@ -860,6 +869,7 @@ fn named( ) -> Option { match token { Token::LookupAccount => Some(answer.account.to_owned()), + Token::LookupHandleOrDid => Some(answer.handle.unwrap_or(answer.account).to_owned()), Token::LookupUri => Some(match decided { Some((_, record)) => record .get("uri") @@ -3466,6 +3476,7 @@ mod lookup_tests { let answers = [Lookup { name: "aiPreference", account: PERSON, + handle: None, uri: &uri, found, }]; diff --git a/crates/didbot-policy/src/subject.rs b/crates/didbot-policy/src/subject.rs index 15aca5b9..e72bbf5a 100644 --- a/crates/didbot-policy/src/subject.rs +++ b/crates/didbot-policy/src/subject.rs @@ -280,6 +280,10 @@ pub struct Lookup<'a> { pub name: &'a str, /// The DID of the repository that was read. pub account: &'a str, + /// The handle the written record itself calls that account by, when it + /// carries one: a mention's text. Nothing checks the DID answers to it, + /// so it is for telling the writer who their own record named. + pub handle: Option<&'a str>, /// The at-uri that was read, so a refusal can carry something a person /// can open. A lookup with no `rkey` reads a collection, and this is /// the collection's uri. diff --git a/crates/didbot-serve/src/lookups.rs b/crates/didbot-serve/src/lookups.rs index 24a4afb6..d34fe640 100644 --- a/crates/didbot-serve/src/lookups.rs +++ b/crates/didbot-serve/src/lookups.rs @@ -18,8 +18,8 @@ use std::sync::{Arc, RwLock}; -use didbot_lookup::{Answer, ForeignRecords, RecordRef}; -use didbot_pds::policy::{OwnedFound, OwnedLookup, WriteLookups}; +use didbot_lookup::{Answer, ForeignRecords, RecordRef, MAX_NAMED_ACCOUNTS}; +use didbot_pds::policy::{LookupRefusal, OwnedFound, OwnedLookup, WriteLookups}; use didbot_pds::Registry; use didbot_policy_records::records::{Accounts, Lookup}; use didbot_policy_records::Lookups; @@ -88,17 +88,18 @@ impl Reader { } } - /// Every record this write's policies want, each once. + /// Every record this write's policies want, each once, or the refusal + /// of a write naming more accounts than they can all be asked about. /// /// An account this deployment holds is not one of them. Its records are /// this server's own, the reader refuses to fetch them over the /// network, and spending a write's lookup budget on a refusal it can /// predict would leave less of it for the strangers the rule is about. - fn wanted(&self, collection: &str, after: Option<&Value>) -> Vec<(Lookup, String, RecordRef)> { + fn wanted(&self, collection: &str, after: Option<&Value>) -> Result { let declared = self.declared.current(); let for_write = declared.for_write(collection); if for_write.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let named: Vec = after .map(didbot_policy_records::named_accounts) @@ -106,6 +107,13 @@ impl Reader { .into_iter() .filter(|did| self.registry.account(did).is_none()) .collect(); + let checked = MAX_NAMED_ACCOUNTS.min(self.foreign.limits().per_write / for_write.len()); + if named.len() > checked { + return Err(LookupRefusal(format!( + "this record names more than {checked} accounts, which is more than this server \ + checks for a declared AI preference; name fewer" + ))); + } let mut wanted = Vec::new(); for lookup in for_write { let accounts: &[String] = match lookup.accounts { @@ -122,14 +130,17 @@ impl Reader { wanted.push((lookup.clone(), account.clone(), reference)); } } - wanted + Ok(wanted) } /// Fills the cache with what this write's policies want and it does not /// already hold. Bounded by `didbot-lookup`'s own deadlines and by how - /// many records one write may read. + /// many records one write may read. A write the write path will refuse + /// for naming too many accounts reads nothing. pub async fn prefetch(&self, collection: &str, after: Option<&Value>) { - let wanted = self.wanted(collection, after); + let Ok(wanted) = self.wanted(collection, after) else { + return; + }; if wanted.is_empty() { return; } @@ -138,9 +149,22 @@ impl Reader { } } +/// What one write wants read: the lookup, the account it is about, and the +/// record to read. +type Wanted = Vec<(Lookup, String, RecordRef)>; + impl WriteLookups for Reader { - fn answers(&self, _account: &str, collection: &str, after: Option<&Value>) -> Vec { - self.wanted(collection, after) + fn answers( + &self, + _account: &str, + collection: &str, + after: Option<&Value>, + ) -> Result, LookupRefusal> { + let handles = after + .map(didbot_policy_records::named_handles) + .unwrap_or_default(); + Ok(self + .wanted(collection, after)? .into_iter() .map(|(lookup, account, reference)| { let uri = reference.at_uri(); @@ -160,13 +184,18 @@ impl WriteLookups for Reader { }, "read what a policy declared it reads about an account this write names" ); + let handle = handles + .iter() + .find(|(did, _)| *did == account) + .map(|(_, handle)| handle.clone()); OwnedLookup { name: lookup.name.clone(), account, + handle, uri, found, } }) - .collect() + .collect()) } } diff --git a/crates/didbot-serve/tests/ai_preference.rs b/crates/didbot-serve/tests/ai_preference.rs index 4fe74c00..fdfbca15 100644 --- a/crates/didbot-serve/tests/ai_preference.rs +++ b/crates/didbot-serve/tests/ai_preference.rs @@ -410,3 +410,87 @@ async fn a_preference_that_changes_is_obeyed_at_the_stated_ttl() { assert_eq!(landed.status, StatusCode::OK, "{:?}", landed.body); stranger.stop(); } + +/// A post mentioning `mentioned`, spelled `@handle` in its text. +fn mention(repo: &str, mentioned: &str, handle: &str) -> Value { + let text = format!("@{handle}"); + json!({ + "repo": repo, + "collection": POST, + "record": { + "$type": POST, + "text": text, + "createdAt": "2026-09-22T00:00:00.000Z", + "facets": [{ + "index": { "byteStart": 0, "byteEnd": text.len() }, + "features": [{ "$type": "app.bsky.richtext.facet#mention", "did": mentioned }], + }], + }, + }) +} + +/// The refusal calls somebody by the handle the writer's own record used +/// for them, and still carries the at-uri, which is where the DID is. +#[tokio::test] +async fn a_refusal_names_a_mentioned_account_by_its_handle() { + let stranger = Stranger::start(vec![global(false)]).await; + let fixture = fixture(); + + let refused = post( + &fixture, + "com.atproto.repo.createRecord", + mention(&fixture.did, &stranger.did, "wren.bsky.social"), + ) + .await; + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{:?}", refused.body); + let message = refused.message(); + assert!( + message.contains("wren.bsky.social has set syntheticContent to false"), + "the refusal calls them by their handle: {message}" + ); + assert!( + message.contains(&format!("at://{}/{PREFERENCE}/self", stranger.did)), + "the refusal carries the record read: {message}" + ); + stranger.stop(); +} + +/// A record naming more accounts than this server checks is refused +/// outright, before anybody's server is asked anything: judging it on the +/// first few would let one extra name carry any name at all. +#[tokio::test] +async fn a_write_naming_more_accounts_than_are_checked_is_refused() { + let fixture = fixture(); + let before = counters(&fixture).await; + let named: Vec = (0..=didbot_lookup::MAX_NAMED_ACCOUNTS) + .map(|n| format!("did:web:person{n}.example")) + .collect(); + + let refused = post( + &fixture, + "com.atproto.repo.createRecord", + json!({ + "repo": fixture.did, + "collection": POST, + "record": { + "$type": POST, + "text": "everyone", + "createdAt": "2026-09-22T00:00:00.000Z", + "tags": named, + }, + }), + ) + .await; + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{:?}", refused.body); + let expected = format!( + "this record names more than {} accounts, which is more than this server checks for \ + a declared AI preference; name fewer", + didbot_lookup::MAX_NAMED_ACCOUNTS + ); + assert!( + refused.message().ends_with(&expected), + "the refusal says why, and what to do: {}", + refused.message() + ); + assert_eq!(counters(&fixture).await, before, "nobody was asked"); +} diff --git a/docs/trust-model.md b/docs/trust-model.md index 5a5115cb..8312673b 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -97,8 +97,10 @@ it is refused. There is nothing to revoke. `community.lexicon.preference.ai` record setting `syntheticContent` to false — at global scope, or at a scope naming this deployment, its operator or one of its accounts — is not named in a record written here. The agent -that tried gets a refusal naming them and the record it was read from, and -the refusal is in the evaluation log. This is what this deployment does, not +that tried gets a refusal naming them — by the handle its own record used for +them, or their DID — and the record it was read from, and the refusal is in +the evaluation log. A record naming more accounts than this server checks is +refused outright. This is what this deployment does, not a claim about their data: every repository involved is public, and nothing here reaches another deployment, what is already federated, or what a third-party application does with a credential this server issued. diff --git a/docs/write-pipeline.md b/docs/write-pipeline.md index 51c96cd9..4284c0c6 100644 --- a/docs/write-pipeline.md +++ b/docs/write-pipeline.md @@ -197,6 +197,11 @@ to the evaluator as a field of the subject. A record the read could not establish is an unknown rather than a value or an absence, and each statement says for itself whether an unknown denies or stands aside. +A write naming more accounts than `didbot_lookup::MAX_NAMED_ACCOUNTS`, while a +policy that declares a lookup applies to it, is refused at stage 7 before any +statement is asked. Judging it on the first few accounts would let one extra +name carry any name at all. + ## A batch is one commit, judged operation by operation `com.atproto.repo.applyWrites` carries several writes and produces exactly one -- 2.51.2