feat(daemon): local self-protection blocklist — complete-mediation enforcement (#162 A2a/A2c) - #186
Merged
Merged
Conversation
…ership-derivation site (#162 A2a/A2c) ADR-039 finding A2: the per-KB local blocklist was designed in `MembershipView` (membership.rs step 7) but entirely unwired — every daemon derive site passed `MembershipView::default()` (empty), with no storage and no setter. Not a live bypass (no block could be set), but the self-protection deny-list it's meant to provide didn't exist. This wires it end-to-end on the daemon side. A2a — daemon core: - Durable `kb_blocklist` table (SqlitePool open + open_memory) + StorageBackend load_blocklist/add_block/remove_block (default-impl'd; SqliteBackend via the primary shard). LOCAL only — never the synced `kbc:` collection, so it is not propagated to peers; durable so a self-protection block survives restart. - DocStore: in-memory cache write-through-backed by storage, `load_blocklists` startup hydration (wired in main), and `membership_view_for(kb_id)` — the single seam replacing `MembershipView::default()` at ALL FOUR derive sites (kb_access, verify_content_op, kb_member_epoch, append_signed_revoke), so a blocked principal is fenced at the access gate AND the content path AND the removal derivation (complete mediation). The relayed content path routes through verify_content_op, so it is covered too. - `kb/block_principal` + `kb/unblock_principal` RPCs. Deliberately NOT owner-gated: blocking even the owner is the explicit capability (you reach for a local block when you cannot get a principal globally removed). Local-only — write-through to storage, never broadcast. Authz floor + the shared-daemon trade-off (and the tighter Unix-operator-socket gate as a follow-up) are documented at the handler. A2c — adversarial tests: - local_block_fences_principal_at_every_derive_site: a blocked editor is denied at kb_access AND rejected by verify_content_op (signature valid — only the blocklist rejects); SELECTIVE (a non-blocked member still derives Edit + applies a signed op); LOCAL-ONLY (the synced op-log still admits the blocked member — no propagated Remove); fences even the OWNER; unblock restores both paths; non-member block is a no-op. - local_block_survives_docstore_reload: load_blocklists rehydrates a durable block across a genuine (file-backed) DocStore restart. - blocklist_round_trips_and_survives_restart (storage level). Scope: the block applies to op-log-derived (anchored / E2e / joined) membership — exactly the world where you need self-protection against a principal you can't get globally removed. An owned non-E2e KB owner uses remove_member directly. Daemon: 120 lib tests pass, clippy clean. Editor workspace untouched. Follow-up PR wires the editor command + Scheme + MCP + *KB Sharing* surface (A2b). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 28, 2026
cuttlefisch
added a commit
that referenced
this pull request
Jun 28, 2026
…cklist (#162 A2b) (#187) PR2 of the ADR-039 A2 local self-protection blocklist. PR1 (#186) landed the daemon enforcement + RPCs; this wires the human + AI peer surface (CLAUDE.md #3) to SET/CLEAR a local block. A follow-up adds the blocked-list display + introspection and closes #162. Mirrors the existing kb-member-remove / kb-set-policy chain end-to-end: - mae-core: CollabIntent::KbSetBlock + KbCollabAction::SetBlock (+ kb_ops conversion). - collab_bridge: CollabCommand::KbBlockPrincipal → JSON-RPC kb/block_principal | kb/unblock_principal (fire-and-forget like set_policy); disconnected → the usual "cannot manage KB" feedback. - Ex-commands :kb-member-block / :kb-member-unblock (empty fp opens the picker). - At-point `b` on a member row in *KB Sharing*, routed BEFORE the owner guard — blocking is NOT owner-gated (you may block even the owner; local-only to your daemon). - Scheme (kb-block-member)/(kb-unblock-member); MCP kb_block_member/kb_unblock_member (descriptions stress LOCAL-only, never propagated — distinct from kb_remove_member). - `b` kb-sharing keybinding; concept:kb-sharing help node documents the commands. Naming follows the established split: ex-command kb-member-*, Scheme kb-*-member, MCP kb_*_member. Tests (principle #14): block→blocked:true / unblock→blocked:false / missing-fp→no intent; at-point block queues the right intent; and the adversarial inverse — non_owner_can_still_block_self_protection (a viewer CAN locally block, where role/remove are owner-rejected). fmt + clippy --workspace --all-targets clean (incl GUI); cargo test --workspace green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cuttlefisch
added a commit
that referenced
this pull request
Jun 28, 2026
…loses #162) PR3 of the ADR-039 A2 local self-protection blocklist — closes #162. PR1 (#186) landed daemon enforcement; PR2 (#187) the human+AI action surface. This adds the display + introspection. Why a fetch (not the local replica): membership renders from the editor's local `kbc:` replica, but the blocklist is LOCAL-only on the daemon and never in the synced collection — so the editor cannot derive it and must fetch the authoritative state. An optimistic local mirror would miss blocks set in a prior session or by another frontend on a shared daemon. Daemon: - DocStore::all_kb_blocklists() + a read-only `kb/blocklist` query RPC returning `{ blocklist: { kb_id: [fp, ...] } }` (optional kb_id scopes to one). The ONLY way a client learns the local-only blocklist. Editor: - CollabState.kb_blocklists cache; a `send_blocklist_fetch` helper sent ON CONNECT (durable prior-session blocks) and AFTER each block/unblock (the user's own changes); reply → PendingResponseKind::Blocklist → CollabEvent::BlocklistUpdated → cache + repaint. Display-only; the daemon stays authoritative. - KbSharingEntry.blocked: Vec<BlockedView> in build_snapshot (label best-effort from the member replica; a non-member block falls back to the short fingerprint). - *KB Sharing* buffer: a foldable "Blocked (N)" section after Pending; at-point `B` → unblock (kb-sharing-unblock, not owner-gated); diagnostic.error theme key. - Introspection: all three actors share build_snapshot, so `blocked` flows into the kb_sharing_status MCP tool and (kb-sharing-status) Scheme primitive (#3/#8). Tests: daemon block-fence test extended with the kb/blocklist query oracle; editor blocklist_renders_blocked_view_with_member_label (bob stays a MEMBER and is listed Blocked with his label; a non-member block has empty label; the view emits a BlockedHeader + one Blocked row per principal). Daemon 120+51 tests + clippy clean; editor fmt + clippy --workspace + test --workspace green (incl GUI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cuttlefisch
added a commit
that referenced
this pull request
Jun 28, 2026
…loses #162) (#189) PR3 of the ADR-039 A2 local self-protection blocklist — closes #162. PR1 (#186) landed daemon enforcement; PR2 (#187) the human+AI action surface. This adds the display + introspection. Why a fetch (not the local replica): membership renders from the editor's local `kbc:` replica, but the blocklist is LOCAL-only on the daemon and never in the synced collection — so the editor cannot derive it and must fetch the authoritative state. An optimistic local mirror would miss blocks set in a prior session or by another frontend on a shared daemon. Daemon: - DocStore::all_kb_blocklists() + a read-only `kb/blocklist` query RPC returning `{ blocklist: { kb_id: [fp, ...] } }` (optional kb_id scopes to one). The ONLY way a client learns the local-only blocklist. Editor: - CollabState.kb_blocklists cache; a `send_blocklist_fetch` helper sent ON CONNECT (durable prior-session blocks) and AFTER each block/unblock (the user's own changes); reply → PendingResponseKind::Blocklist → CollabEvent::BlocklistUpdated → cache + repaint. Display-only; the daemon stays authoritative. - KbSharingEntry.blocked: Vec<BlockedView> in build_snapshot (label best-effort from the member replica; a non-member block falls back to the short fingerprint). - *KB Sharing* buffer: a foldable "Blocked (N)" section after Pending; at-point `B` → unblock (kb-sharing-unblock, not owner-gated); diagnostic.error theme key. - Introspection: all three actors share build_snapshot, so `blocked` flows into the kb_sharing_status MCP tool and (kb-sharing-status) Scheme primitive (#3/#8). Tests: daemon block-fence test extended with the kb/blocklist query oracle; editor blocklist_renders_blocked_view_with_member_label (bob stays a MEMBER and is listed Blocked with his label; a non-member block has empty label; the view emits a BlockedHeader + one Blocked row per principal). Daemon 120+51 tests + clippy clean; editor fmt + clippy --workspace + test --workspace green (incl GUI). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Wires ADR-039 finding A2 (
docs/SECURITY_REVIEW.md§4) — the local self-protection blocklist — end-to-end on the daemon side. This is PR 1 of 2 for #162 (daemon core + adversarial tests); a follow-up PR adds the editor/Scheme/MCP/*KB Sharing*surface (A2b) and closes the issue.The blocklist was designed into
MembershipView(shared/sync/membership.rsstep 7) but entirely unwired — every daemon derive site passedMembershipView::default()(empty), with no storage and no setter. Not a live bypass (no block could be set), but the self-protection deny-list it's meant to provide didn't exist.A2a — daemon core
kb_blocklisttable (SqlitePool::open+open_memory) +StorageBackend::{load_blocklist,add_block,remove_block}(default-impl'd;SqliteBackenduses theprimary()shard). LOCAL only — never written to the syncedkbc:collection, so it is never propagated to peers; durable so a self-protection block survives restart (an evaporating deny-list is weak).DocStore: an in-memory cache write-through-backed by storage,load_blocklists()startup hydration (wired inmain), andmembership_view_for(kb_id)— the single seam replacingMembershipView::default()at all four derive sites (kb_access,verify_content_op,kb_member_epoch,append_signed_revoke). A blocked principal is fenced at the access gate AND the content path AND the removal derivation (complete mediation). The relayed content path routes throughverify_content_op, so it's covered too.kb/block_principal+kb/unblock_principalRPCs. Deliberately NOT owner-gated — blocking even the owner is the explicit capability (you reach for a local block precisely when you cannot get a principal globally removed, e.g. you lack quorum). Local-only (write-through, never broadcast). The authz floor + the shared-daemon trade-off (and the tighter Unix-operator-socket gate as a hardening follow-up) are documented at the handler.A2c — adversarial tests (principle #14)
local_block_fences_principal_at_every_derive_site— the attacker's test: a blocked editor is denied atkb_accessAND rejected byverify_content_op(the signature is valid — only the blocklist rejects it); SELECTIVE (a non-blocked member still derivesEdit+ applies a signed op — not a blanket failure); LOCAL-ONLY (the synced op-log still admits the blocked member — no propagatedRemove); fences even the OWNER; unblock restores both paths; a non-member block is a no-op.local_block_survives_docstore_reload—load_blocklistsrehydrates a durable block across a genuine (file-backed)DocStorerestart.blocklist_round_trips_and_survives_restart(storage level).Scope note
The block applies to op-log-derived membership (anchored / E2e / joined KBs) — exactly the world where you need self-protection against a principal you can't get globally removed. An owned non-E2e KB owner manages members directly via
remove_member, so there's no self-protection gap there.Verification
clippy --all-targets -D warningsclean,cargo fmt --checkclean.Part of #162.
🤖 Generated with Claude Code