Repository navigation
Description
Problem
SignedArtifact::parsed() validates every field of the other known artifact kinds — but a friend_remove body's requests list is checked for nothing at all: not its length, not the shape of its entries, not their size.
Evidence on main (b3b9ad33, pkgs/id/src/artifact.rs:318-335): the FRIEND_REMOVE arm of parsed() decodes FriendRemove, calls check_key on a and b, refuses a == b, and requires the author to be one of them. It never inspects body.requests. Compare the sibling arms in the same function: HOME_DECLARED caps servers at MAX_HOME_SERVERS and validates each endpoint/world, DOCUMENT caps content at MAX_DOCUMENT_BYTES, TAG requires a hash-shaped subject and caps name (64 B) and value (256 B). FriendRemove is defined at artifact.rs:139-148 with requests: Vec<String> and #[serde(default)] — no bound anywhere.
Every legitimate producer builds this list in exactly one place, Directory::friendship_requests (pkgs/id/src/directory.rs:1710-1721): the request that formed the friendship (friend_requests, at most one) plus pending_id(a, b) and pending_id(b, a) (at most one each), sorted and deduped — at most 3 IDs. And every real request ID is an envelope ID: Envelope::signed (directory.rs:366-376) sets id = hex_encode(&nonce) over a 16-byte nonce, i.e. exactly 32 lowercase hex characters.
Verified this run
Laptop probe ~/git/fleet/probe-2026-10-31-laneB-friendremove/ (verbatim test against the laptop checkout, whose artifact.rs/directory.rs sha256-match sandbox main; output in probe.txt there): a friend_remove artifact signed by account a, naming 100 request IDs — 99 of the form not-an-envelope-id-<n> and one 500-character string — passes both gates:
artifact.parsed()returnsOk(Some(Kind::FriendRemove(..)))with all 100 entries verbatim;ArtifactLog::insert(..)accepts it asStored::New.
Why it matters
The list is not inert. Directory::receive_artifact (directory.rs:1392-1403) applies a FriendRemoved entry whenever requests is non-empty, even for a pair that was never friends, and Directory::apply (directory.rs:1698-1705) extends the permanent removed_requests set with every string in the list. That set is the void guard consulted for every future FriendRequested entry (directory.rs:1665-1671) and it is replayed from the journal on every open, so junk entries persist and are re-processed forever. One signed artifact (bounded only by the 1 MiB frame) can therefore plant thousands of arbitrary strings — up to ~1 MiB of them — into every receiving server's directory state, none of which can ever name a real request. It also breaks the invariant the rest of the module maintains: a stored known-kind artifact is one whose body passed the same shape rules its producers obey.
Acceptance criteria
-
SignedArtifact::parsed()(or an equivalent single validation point on the receive/store path) refuses afriend_removebody whoserequestshas more entries than a legitimate removal can carry (at most 3 today: formed-by + one pending per direction; if a named constant is introduced, producers and the check share it). - Each entry in
requestsmust be a well-formed envelope/request ID (32 lowercase hex characters, matchingEnvelope::signed'shex_encodeof a 16-byte nonce); anything else is refused with aRefusal::Forbiddennaming the rule, consistent with the sibling arms. - A test signs a
friend_removewith (a) 4 well-formed IDs, (b) one non-hex / wrong-length ID, and (c) one over-long ID string, and asserts all three are refused byparsed()and byArtifactLog::insert/Directory::receive_artifact; a legitimate removal produced byDirectory::remove_friend(viafriendship_requests) still parses, stores, and applies. - No change to the wire format, to
FriendRemove's fields, or to the semantics ofremoved_requestsfor legitimate removals.
Distinct from: #2637 / #2404 / #1899 (directory_mail validation gaps — different module, different fields); the tag/document/home-declared caps above (those kinds are already validated; this is the one arm that is not).
Activity
dezren39 commentedon Oct 31, 2026
🤖 Agent note · filed
friend_removeis the one known artifact kind whose bodySignedArtifact::parsed()never validates — itsrequestslist passes at any count, shape or length, and every string is permanently extended into the directory'sremoved_requestsvoid guard on apply.FRIEND_REMOVEarm, with the refusal tests named in the acceptance criteria.~/git/fleet/probe-2026-10-31-laneB-friendremove/(probe_friendremove.rs + probe.txt)dezren39 commentedon Oct 31, 2026
🤖 Design-architect pass 28 (2026-10-31) — agent note: this issue is ADOPTED as a phase of design #3231 ("a signed artifact is validated as strictly as it is built", design doc: files/designs/code-id-artifact-receipt-validation.md in the keep-the-four-repos-healthy goal workspace), linked as a sub-issue of #3231 and labelled source/design-architect. Triage: do NOT re-file, re-scope or split this issue — record the phase structure instead. Phase mapping: Phase 1 (list integrity) = #3196 + #3146; Phase 2 (text fields) = #3192 + #3187; Phase 3 (closing audit, gated on Phases 1–2) = #3232. The design records the pins that span the phases: duplicates in a signed list are REFUSED, never silently deduped on receipt; text fields use the crate's existing rules (unicode_normalization::is_nfc; world_id under MAX_WORLD_ID_BYTES=64, not whitespace-only, no Cc/Cf); unknown artifact kinds stay stored-but-never-applied and envelope-only. Each origin keeps its own acceptance criteria and its already-queued triage packet — no new packet was created for it this pass.
28 remaining items
dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up
requestsis the one known-kind body parsed() never validates — any count, any shape, any length passes, and every string lands permanently in the directory's removed_requests void guard #3146 — PR fix(id): validate friend_remove request IDs #4368 already implements friend_removerequestsvalidation inparsed(); verifying checks/Codex state fresh, then merging PR fix(id): validate friend_remove request IDs #4368 when green/clean.requestsis the one known-kind body parsed() never validates — any count, any shape, any length passes, and every string lands permanently in the directory's removed_requests void guard #3146 closed, or task 5756 parked again with a precise resume condition.queue/5756-3146-friend-remove-validation, head 4d58d23)dezren39 commentedon Nov 2, 2026
🤖 Agent note · done
requestsis the one known-kind body parsed() never validates — any count, any shape, any length passes, and every string lands permanently in the directory's removed_requests void guard #3146 is done — PR fix(id): validate friend_remove request IDs #4368 merged (merge sha 3cbb607) and issue id(artifact): friend_removerequestsis the one known-kind body parsed() never validates — any count, any shape, any length passes, and every string lands permanently in the directory's removed_requests void guard #3146 closed.parsed()now refuses friend_remove bodies with >3 requests or non-32-lowercase-hex IDs as Forbidden before storage; regressions at parsed(), ArtifactLog::insert, and Directory::receive_artifact; legitimate removal round trip preserved.cargo test --lib friend_removal(default features, laptop worktree for task 5756, head 4d58d23): 3 passed, 0 failed (friend_removal_refuses_too_many_or_malformed_request_ids_before_storage,receive_artifact_refuses_friend_removal_with_too_many_requests_without_storing_it,a_local_friend_removal_needs_no_outbox). The--no-default-featuresvariant fails to compile pre-existing insrc/chat.rs/src/invite.rs(world-feature gating, files PR fix(id): validate friend_remove request IDs #4368 does not touch). Codex P2 on PR fix(id): validate friend_remove request IDs #4368 was dispositioned earlier (reply on the thread; tracked for the Phase 3 (audit) of #3231 — field-by-field receipt-validation audit for pkgs/id artifacts + contract statement #3232 audit). PR guard checks were still QUEUED in the fleet runner backlog at merge time.queue/5756-3146-friend-remove-validationid(directory): replay-aware parsing in recovery scans (Codex P2 on #5576
fix(id/directory): apply legacy removals in read-only replay (#5624)
fix(id/directory): validate friend-request IDs at admission, preserve…