LearnNewsExamplesServices
Frontmatter
id10255
titlePer-agent blocking in open reply policy must override default-open (#10253 follow-up)
stateClosed
labels
enhancementaiarchitecturecore
assigneesneo-opus-ada
createdAtApr 23, 2026, 8:12 PM
updatedAtApr 24, 2026, 12:13 AM
githubUrlhttps://github.com/neomjs/neo/issues/10255
authorneo-opus-ada
commentsCount0
parentIssue10252
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtApr 24, 2026, 12:13 AM

Per-agent blocking in open reply policy must override default-open (#10253 follow-up)

Closed v13.0.0/archive-v13-0-0-chunk-5 enhancementaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Apr 23, 2026, 8:12 PM

Context

PR #10253 (resolves #10252) shipped the config-gated aiConfig.mailbox.defaultReplyPolicy: 'open' | 'blocked' selector. In 'open' mode (the library default for homogeneous trusted-frontier swarms), any authenticated agent can DM any other authenticated agent. All PermissionService primitives — grantPermission, revokePermission, CAN_REPLY_TO graph edges — remain live but the enforcement path in MailboxService.addMessage skips them entirely.

During Gemini's cross-family review of #10253, she flagged an architectural gap in her Depth Floor:

"If an operator explicitly uses revokePermission to remove a CAN_REPLY_TO edge in 'open' mode, it has no enforcement effect. We may eventually need a hybrid model or a negative BLOCKED_BY edge if local-swarm agent misbehavior becomes a real issue, but for now, the binary toggle is sufficient."

Tobi's response: "blocking should not be a no-op in open mode. definitely worth a ticket."

This ticket scopes that follow-up. The concern is real: currently the only way to isolate a single noisy or misbehaving agent is to flip the entire swarm to 'blocked' mode, which is disproportionate and breaks the UX for every well-behaved peer. Operators need per-agent enforcement granularity that works within the 'open' default.

The Problem

The existing validScopes in PermissionService.mjs:42 are all positive capability edges:

['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO']

In 'open' reply policy, the enforcement path at MailboxService.mjs:~110 skips the CAN_REPLY_TO grant check entirely. revokePermission successfully removes the edge from the graph, but the subsequent addMessage call still succeeds because the enforcement path is gated by strictReplyPolicy and the removed edge is never consulted.

Concretely, this means:

  • Bob cannot mute a noisy Alice without flipping the whole deployment to 'blocked' mode
  • revokePermission(CAN_REPLY_TO, from: alice, to: bob) is effectively a no-op observable only via listPermissions
  • The positive-capability-only scope set is architecturally insufficient for the 'open' mode's need

The Architectural Reality

The primitive needed is a negative-intent edge that operates as an override, not a capability. Unlike CAN_REPLY_TO (which grants reach in a default-deny world), BLOCKED_BY must revoke reach in a default-allow world.

Edge semantics (consistent with existing scope naming convention):

  • A BLOCKED_BY B — source: A (the blocked sender), target: B (the blocking recipient)
  • Created by: recipient calls grantPermission({to: sender-to-block, scope: 'BLOCKED_BY'}) — the "grant the block" framing parallels the existing capability-edge directionality
  • Enforced in: MailboxService.addMessage write-path, consulted in BOTH modes (see below)

Interaction with defaultReplyPolicy:

Mode Default BLOCKED_BY effect
'open' Allow Acts as per-agent override: hasPermission(sender, recipient, 'BLOCKED_BY') → reject with Unauthorized
'blocked' Deny unless granted/trust-lifted Redundant-but-safe: if CAN_REPLY_TO is absent, block fires anyway; if CAN_REPLY_TO is present, BLOCKED_BY still overrides (block wins)

The "block wins" semantic in 'blocked' mode matches operator intent — if a recipient has explicitly blocked a sender, re-granting CAN_REPLY_TO later via grantPermission should not silently re-enable reach. Block takes precedence until explicitly revoked.

Architectural relationship to #10251 (closed as superseded):

#10251 proposed BLOCKED_BY as a full substrate flip — replacing CAN_REPLY_TO with a blocklist model. That was rejected because it destroyed the multi-user Memory Core deployment path (per #10146 strict-isolation primitive). This ticket takes the scope name from #10251 but applies it as an ADDITIVE primitive alongside the config-gated default — the architectural shape #10251 should have had in the first place. Credit to Gemini for the original scope-name intuition.

The Fix

  1. Extend validScopes in ai/mcp/server/memory-core/services/PermissionService.mjs:42:

       validScopes = ['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO', 'BLOCKED_BY']
  2. Wire enforcement in ai/mcp/server/memory-core/services/MailboxService.mjs:~106-140 (atop the existing config-gated block):

       // BLOCKED_BY check fires in BOTH reply-policy modes — operator explicit blocks
    // always override both the 'open' default-allow AND the 'blocked'-mode reachable-
    // counterparty trust-lift. Block wins; re-granting CAN_REPLY_TO does not silently
    // re-enable reach. To restore reach, the recipient must explicitly revokePermission
    // the BLOCKED_BY edge.
    if (!isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy) {
        if (PermissionService.hasPermission(sentBy, to, 'BLOCKED_BY')) {
            throw new Error(`Unauthorized: ${to} has blocked messages from ${sentBy}.`);
        }
    }
    
    // existing strict-mode check (unchanged)
    if (strictReplyPolicy && !isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy) {
        // ... existing CAN_REPLY_TO grant check + trust-lift ...
    }
  3. Tests (test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs):

    • New describe block or additions to both existing 'blocked' + 'open' mode suites
    • Scenarios:
      • 'open' mode + Bob blocks Alice → Alice's DM to Bob rejected; Alice's broadcast reaches Bob (broadcasts bypass BLOCKED_BY — recipient-unaware at write time)
      • 'open' mode + Bob blocks Alice → Bob's DM to Alice still succeeds (block is directional — Alice cannot reach Bob, but Bob can still reach Alice)
      • 'blocked' mode + Bob grants CAN_REPLY_TO to Alice + Bob then blocks Alice → block wins, Alice's DM rejected
      • revokePermission({to: alice, scope: 'BLOCKED_BY'}) removes the block; Alice's DM resumes working (subject to the mode's base enforcement)
      • listPermissions surfaces BLOCKED_BY edges in the grantedToOthers projection
  4. Documentation (learn/agentos/tooling/MemoryCoreMcpAuth.md):

    • Extend §Cross-Tenant Permissions "Valid Scopes" list to include BLOCKED_BY
    • New subsection or extension under §Reply Policy Deployment Modes clarifying the "block wins" precedence and the broadcast bypass

Acceptance Criteria

  • BLOCKED_BY added to validScopes array in PermissionService.mjs
  • Enforcement wired in MailboxService.addMessage above the existing config-gated check; fires in BOTH 'open' and 'blocked' modes
  • Block semantic is directional — A blocking B rejects B's DMs to A, does not affect A's DMs to B
  • Block wins over CAN_REPLY_TO grants in 'blocked' mode — re-granting does not silently restore reach
  • Broadcasts (to: 'AGENT:*') bypass BLOCKED_BY — recipients filter at read time, not at write time
  • grantPermission({to, scope: 'BLOCKED_BY'}) / revokePermission({to, scope: 'BLOCKED_BY'}) / listPermissions all work with the new scope without additional code changes (validScopes extension suffices)
  • 4+ regression tests covering the scenarios above
  • MemoryCoreMcpAuth.md updated with the new scope in the Valid Scopes table and a block-precedence note in §Reply Policy Deployment Modes
  • Empirical validation via ai/mcp/client/mcp-cli.mjs: manually block a peer, verify DM fails; revoke block, verify DM resumes

Out of Scope

  • Global allowlist/blocklist that applies to all senders regardless of grant state — defer until empirical demand
  • Auto-expiry on BLOCKED_BY edges (e.g., "block expires after 24h") — additional complexity, defer until need
  • Notification to blocked sender that their message was rejected — information-disclosure concern (unblocks attacker to probe block state); recipients should not be required to notify blockers by design
  • Rate limiting — orthogonal concern mentioned in MailboxService.mjs:117 but still deferred until the spam surface materializes empirically
  • Role or human target blocking — blocks apply only to direct AgentIdentity → AgentIdentity edges; role/human targets remain write-permissive
  • UI for block management — MCP tool surface is sufficient for agent-driven administration

Avoided Traps

  • "Reuse revokePermission(CAN_REPLY_TO) as the block primitive": rejected. That was Gemini's Depth Floor flag — in 'open' mode, the enforcement path doesn't consult the scope, so revocation is a no-op. Positive capability revocation is architecturally wrong for negative intent; a dedicated negative-intent edge type is the coherent solution.
  • "Flip to blocklist substrate (replace CAN_REPLY_TO with BLOCKED_BY)": rejected per #10251's supersession rationale. BLOCKED_BY is ADDITIVE to CAN_REPLY_TO, not a replacement. Both modes retain their existing semantics; BLOCKED_BY operates as an override layer above them.
  • "Make BLOCKED_BY only fire in 'open' mode": rejected. Operators blocking in 'blocked' mode expect the block to persist even if they later re-grant CAN_REPLY_TO (e.g., for a scoped trust window). Block wins across modes is the coherent precedence.
  • "Use reachable-counterparty SENT_TO edge deletion as the block mechanism": rejected. SENT_TO edges are write-side history artifacts; mutating them to effect blocks would corrupt the audit trail and break thread reconstruction. Block must be a separate edge type.

Related

  • Parent: #10252 (config-gated reply policy — this ticket extends the spectrum)
  • #10253 (PR merging #10252) — source of the Depth Floor flag that filed this
  • #10251 (CLOSED as superseded — originally proposed BLOCKED_BY as substrate flip; this ticket is the refined additive-primitive version)
  • #10146 (cross-tenant permission edges framework — extends the validScopes set this ticket modifies)
  • #10139 (mailbox A2A primitive — transitive parent)
  • #10179 (reachable-counterparty trust-lift — block wins over trust-lift in 'blocked' mode, per precedence)
  • #9999 (cloud-native multi-tenant epic — multi-user deployments will exercise this primitive as part of noisy-tenant mitigation)

Origin Session ID

Origin Session ID: 7ad90eb3-2218-4200-977b-fbfe546d64a8

Handoff Retrieval Hints

Retrieval Hint: "BLOCKED_BY open mode per-agent mute reply policy override" Retrieval Hint: "additive negative-intent edge type vs substrate flip" Retrieval Hint: "block wins precedence CAN_REPLY_TO re-grant" Cross-reference: PR #10253 comments (Gemini's Depth Floor challenge + tobi's response authorizing this follow-up)

tobiu referenced in commit 770cc6b - "feat(ai): per-agent blocking in open reply policy (#10255) (#10266) on Apr 24, 2026, 12:13 AM
tobiu closed this issue on Apr 24, 2026, 12:13 AM