Skip to content

feat(daemon): local self-protection blocklist — complete-mediation enforcement (#162 A2a/A2c) - #186

Merged
cuttlefisch merged 1 commit into
mainfrom
feat/162-local-blocklist
Jun 28, 2026
Merged

feat(daemon): local self-protection blocklist — complete-mediation enforcement (#162 A2a/A2c)#186
cuttlefisch merged 1 commit into
mainfrom
feat/162-local-blocklist

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

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.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.

A2a — daemon core

  • Durable kb_blocklist table (SqlitePool::open + open_memory) + StorageBackend::{load_blocklist,add_block,remove_block} (default-impl'd; SqliteBackend uses the primary() shard). LOCAL only — never written to the synced kbc: 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 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). 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's 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 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 at kb_access AND rejected by verify_content_op (the signature is valid — only the blocklist rejects it); SELECTIVE (a non-blocked member still derives Edit + applies a signed op — not a blanket failure); LOCAL-ONLY (the synced op-log still admits the blocked member — no propagated Remove); fences even the OWNER; unblock restores both paths; a non-member block is a no-op.
  • local_block_survives_docstore_reloadload_blocklists rehydrates a durable block across a genuine (file-backed) DocStore restart.
  • 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

  • Daemon: 120 lib tests pass, clippy --all-targets -D warnings clean, cargo fmt --check clean.
  • Editor workspace untouched (daemon-internal change).

Part of #162.

🤖 Generated with Claude Code

…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>
@cuttlefisch cuttlefisch added the release:none Skip version bump on merge label Jun 28, 2026
@cuttlefisch
cuttlefisch merged commit e91b691 into main Jun 28, 2026
14 checks passed
@cuttlefisch
cuttlefisch deleted the feat/162-local-blocklist branch June 28, 2026 22:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:none Skip version bump on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant