Skip to content

SWM gossip in curated context graphs is not gated by DKG_ALLOWED_AGENT — agent-address allowlists are advisory metadata only #396

@Jurij89

Description

@Jurij89

Summary

The Shared Working Memory (SWM) gossip handler has a single enforcement gate that queries only the DKG_ALLOWED_PEER allowlist. As a result, a context graph that is private according to every other check in the system can still gossip plaintext SWM data to every peer subscribed to its gossip topic. There are at least three distinct states where this leak occurs, all of which look "private" to operators today.

The privacy classifier isPrivateContextGraph() (packages/agent/src/dkg-agent.ts:6254-6304) correctly recognizes a CG as private when:

  • It has an explicit DKG_ACCESS_POLICY: "private" triple (set by accessPolicy: 1 at creation), OR
  • It has any DKG_ALLOWED_AGENT / DKG_PARTICIPANT_AGENT / DKG_ALLOWED_PEER allowlist entry.

But the SWM gossip handler at packages/publisher/src/workspace-handler.ts:138-142 does NOT consult isPrivateContextGraph(). It calls getContextGraphAllowedPeers() (workspace-handler.ts:310-324), which queries ONLY DKG_ALLOWED_PEER and returns null (interpreted as "open / all peers allowed") when no entries are found.

The result: any of the following three states leak SWM gossip in plaintext:

State isPrivateContextGraph() SWM gossip gate behavior
accessPolicy: 1 set; no allowlist of any kind true OPEN (admits all peers)
accessPolicy: 1 set; only DKG_ALLOWED_AGENT populated true OPEN (admits all peers)
no accessPolicy; only DKG_ALLOWED_AGENT populated (e.g. inviteAgentToContextGraph was called) true OPEN (admits all peers)

Only the state "any DKG_ALLOWED_PEER populated" actually constrains gossip. This is a confidentiality enforcement gap.

Reproduction

Case 1: explicit accessPolicy: 1 with empty allowlist

  1. Create a context graph with POST /api/context-graph/create passing { id, name, accessPolicy: 1 }.
  2. Confirm isPrivateContextGraph(cgId) returns true (the explicit policy triple is recognized).
  3. Have any peer outside the (empty) allowlist subscribe to the CG's SWM gossip topic (paranetWorkspaceTopic(contextGraphId)).
  4. Have an authorized agent write SWM via /api/shared-memory/write with localOnly: false.
  5. The outside peer receives the SWM data in plaintext.

Case 2: agent-address allowlist populated, peer-ID allowlist empty

  1. Create a context graph (any access policy).
  2. Call inviteAgentToContextGraph(cgId, agentAddress) — writes DKG_ALLOWED_AGENT to _meta. Flips isPrivateContextGraph(cgId) to true.
  3. Outside peer subscribes to the gossip topic.
  4. Authorized agent writes SWM with localOnly: false.
  5. Outside peer receives plaintext.

Case 3: explicit accessPolicy: 1 AND agent-address allowlist, peer-ID allowlist empty

Same as Case 2 plus the explicit policy. Same outcome — gossip leaks because no DKG_ALLOWED_PEER is set.

Root cause (code references)

The SWM gossip handler:

// packages/publisher/src/workspace-handler.ts:138-142
const allowedPeers = await this.getContextGraphAllowedPeers(contextGraphId);
if (allowedPeers !== null && !allowedPeers.includes(fromPeerId)) {
  this.log.warn(ctx, `SWM write rejected: peer "${fromPeerId}" not in allowlist...`);
  return;
}

Allowlist resolver — queries only DKG_ALLOWED_PEER:

// packages/publisher/src/workspace-handler.ts:310-324
private async getContextGraphAllowedPeers(contextGraphId: string): Promise<string[] | null> {
  const DKG_ALLOWED_PEER = 'https://dkg.network/ontology#allowedPeer';
  const cgMeta = contextGraphMetaUri(contextGraphId);
  const cgData = contextGraphDataUri(contextGraphId);
  const result = await this.store.query(
    `SELECT ?peer WHERE { GRAPH <${cgMeta}> { <${cgData}> <${DKG_ALLOWED_PEER}> ?peer } }`,
  );
  if (result.type !== 'bindings' || result.bindings.length === 0) {
    return null;     // ← "no allowlist set, open CG"
  }
  return result.bindings.map(row => row['peer']).filter((v): v is string => typeof v === 'string').map(v => v.replace(/^"|"$/g, ''));
}

null from this helper becomes the "admit every peer" sentinel at line 139, regardless of whether accessPolicy: "private" is set or DKG_ALLOWED_AGENT is populated.

Meanwhile, the same allowlist concepts ARE honored elsewhere:

  • isPrivateContextGraph(cgId) — returns true if either explicit DKG_ACCESS_POLICY: "private" OR any allowlist entry (peer/agent/participant) exists (packages/agent/src/dkg-agent.ts:6262-6301).
  • On-chain registration publishPolicy is derived from isPrivateContextGraph() and locked in at registration time (dkg-agent.ts:4114-4121).
  • Local SPARQL access checks via canReadContextGraph() (dkg-agent.ts:3317).
  • The agent's CG-subscribe handler rejects subscription requests from non-allowlisted peers.

So the agent-address allowlist and explicit access policy are honored everywhere EXCEPT the SWM gossip data path — which is exactly the path that operators most plausibly assume protects their data when they curate a CG.

Why this is confusing for operators

There are two separate invitation flows in the codebase:

Flow Triple written Gates SWM gossip?
inviteAgentToContextGraph(cgId, agentAddress) (dkg-agent.ts:4368-4430) DKG_ALLOWED_AGENT No
inviteToContextGraph(cgId, peerId) (dkg-agent.ts:4290-4340) DKG_ALLOWED_PEER Yes

Both flows are reachable. The first is the more natural entrypoint for tooling ("invite Alice's agent address"). Naming and asymmetric enforcement are easy to mismatch.

Additionally, there is no canonical mapping between agent addresses and libp2p peer IDs — an operator who wants to invite Alice "just" by agent address has no documented path to also populate her peer ID into DKG_ALLOWED_PEER. So even an attentive operator can land in one of the leaky states.

What accessPolicy actually constrains today

For completeness — accessPolicy: 1 (curated mode) currently affects:

  • Write side of the SWM gossip path: only allowlisted PEERS can publish to a curated CG's SWM topic.
  • Subscribe / sync side of the agent's CG subscription: a peer not in the allowlist is rejected when it tries to subscribe to a curated CG's gossip topic.
  • On-chain registration policy (publishPolicy): determines whether VM publish goes through the curated or open contract path.
  • It does NOT encrypt anything; private payloads are plaintext, gated by libp2p peer-ID allowlists at the request-time protocol level (packages/publisher/src/access-handler.ts:86-110).

What this is NOT

  • Not a key-revocation issue. There are no cryptographic keys to revoke; access control is network-layer.
  • Not a chain-level issue. accessPolicy lives in node-local _meta / ontology graph, not on-chain (smart contract has no allowlist concept; it tracks only publishPolicy).
  • Not a tooling issue per se. The tools faithfully call inviteAgentToContextGraph or pass accessPolicy: 1 and observe isPrivateContextGraph() returning true. The gap is that the SWM gossip handler doesn't honor the same definition of "private" that everything else uses.

Suggested fix directions (for design discussion)

  1. Have the gossip handler consult isPrivateContextGraph(). When the function returns true but DKG_ALLOWED_PEER is empty, the handler must NOT default to "admit all" — it must either:

    • Reject all SWM writes for that CG (fail-closed; operator-visible error), OR
    • Resolve the agent allowlist to peer IDs at evaluation time (requires the agent→peer mapping discussed below), OR
    • Encrypt/sign payloads (bigger redesign).
  2. Reconcile the two allowlists. Either:
    a. Make inviteAgentToContextGraph() also write DKG_ALLOWED_PEER triples, requiring the operator to register a peer ID alongside the agent address.
    b. Teach getContextGraphAllowedPeers() to also resolve DKG_ALLOWED_AGENT to peer IDs at query time, requiring a canonical agent→peer registry.
    c. Drop DKG_ALLOWED_AGENT entirely and require DKG_ALLOWED_PEER for all invitations. Simplest but loses the abstraction the agent layer offers.

  3. Sign every gossip message and verify the signer is in DKG_ALLOWED_AGENT. Decouples agent identity from libp2p peer ID and lets the gossip handler enforce the agent-level allowlist directly. Adds a public-key-recovery check on every received SWM message.

  4. Encrypt SWM payloads with a CG-scoped key wrapped to allowed agents' identity keys. Strongest guarantee (gossip ciphertext is meaningless to outsiders) but a much bigger change.

  5. Short-term mitigation: Refuse SWM gossip publish when isPrivateContextGraph(cgId) === true but the gossip handler cannot prove the recipient set is bounded (i.e. DKG_ALLOWED_PEER is empty). Returns an error to the publishing daemon so the leak fails loudly instead of silently. This is the cheapest stop-gap that closes ALL THREE states above while a proper fix lands.

Acceptance criteria for any fix

  • In every state where isPrivateContextGraph(cgId) === true, a peer outside the effective allowlist must NOT receive plaintext SWM gossip. This must hold for ALL of: explicit accessPolicy: 1 with empty allowlist, DKG_ALLOWED_AGENT populated with empty DKG_ALLOWED_PEER, and combinations of the two.
  • isPrivateContextGraph(cgId) returning true must imply effective gating on the gossip path, not just on metadata helpers.
  • Removing an agent from DKG_ALLOWED_AGENT must (at minimum) prevent future SWM gossip from reaching that agent's nodes. Retroactive deletion of already-replicated data is out of scope per the V10 model, but the future-receipt revocation must work.
  • Documentation and tool surfaces clearly distinguish agent-address allowlists from peer-ID allowlists, OR collapse them into a single concept.
  • Operators can no longer create a CG that appears private (isPrivateContextGraph true) but gossips openly. Either the configuration is rejected at create/invite time, or the gossip handler enforces correctly without operator intervention.

Out of scope

  • Cryptographic encryption of payloads (mentioned as option 4 but not required for this issue).
  • Retroactive revocation of data already replicated to a removed peer.
  • Rewriting on-chain publishPolicy after registration (already documented as immutable).

Related code

  • packages/publisher/src/workspace-handler.ts:119-324 — SWM gossip handler + allowlist helper
  • packages/agent/src/dkg-agent.ts:4290-4430 — both invitation flows side by side
  • packages/agent/src/dkg-agent.ts:6254-6304isPrivateContextGraph() definition (explicit policy + allowlist fallback)
  • packages/agent/src/dkg-agent.ts:5032-5036 — agent-side helper that mirrors the gossip handler's filter
  • packages/publisher/src/access-handler.ts:86-110/dkg/access/1.0.0 per-request authorization (peer-list-based)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions