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
- Create a context graph with
POST /api/context-graph/create passing { id, name, accessPolicy: 1 }.
- Confirm
isPrivateContextGraph(cgId) returns true (the explicit policy triple is recognized).
- Have any peer outside the (empty) allowlist subscribe to the CG's SWM gossip topic (
paranetWorkspaceTopic(contextGraphId)).
- Have an authorized agent write SWM via
/api/shared-memory/write with localOnly: false.
- The outside peer receives the SWM data in plaintext.
Case 2: agent-address allowlist populated, peer-ID allowlist empty
- Create a context graph (any access policy).
- Call
inviteAgentToContextGraph(cgId, agentAddress) — writes DKG_ALLOWED_AGENT to _meta. Flips isPrivateContextGraph(cgId) to true.
- Outside peer subscribes to the gossip topic.
- Authorized agent writes SWM with
localOnly: false.
- 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)
-
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).
-
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.
-
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.
-
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.
-
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
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-6304 — isPrivateContextGraph() 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)
Summary
The Shared Working Memory (SWM) gossip handler has a single enforcement gate that queries only the
DKG_ALLOWED_PEERallowlist. 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:DKG_ACCESS_POLICY: "private"triple (set byaccessPolicy: 1at creation), ORDKG_ALLOWED_AGENT/DKG_PARTICIPANT_AGENT/DKG_ALLOWED_PEERallowlist entry.But the SWM gossip handler at
packages/publisher/src/workspace-handler.ts:138-142does NOT consultisPrivateContextGraph(). It callsgetContextGraphAllowedPeers()(workspace-handler.ts:310-324), which queries ONLYDKG_ALLOWED_PEERand returnsnull(interpreted as "open / all peers allowed") when no entries are found.The result: any of the following three states leak SWM gossip in plaintext:
isPrivateContextGraph()accessPolicy: 1set; no allowlist of any kindtrueaccessPolicy: 1set; onlyDKG_ALLOWED_AGENTpopulatedtrueaccessPolicy; onlyDKG_ALLOWED_AGENTpopulated (e.g.inviteAgentToContextGraphwas called)trueOnly the state "any
DKG_ALLOWED_PEERpopulated" actually constrains gossip. This is a confidentiality enforcement gap.Reproduction
Case 1: explicit
accessPolicy: 1with empty allowlistPOST /api/context-graph/createpassing{ id, name, accessPolicy: 1 }.isPrivateContextGraph(cgId)returnstrue(the explicit policy triple is recognized).paranetWorkspaceTopic(contextGraphId))./api/shared-memory/writewithlocalOnly: false.Case 2: agent-address allowlist populated, peer-ID allowlist empty
inviteAgentToContextGraph(cgId, agentAddress)— writesDKG_ALLOWED_AGENTto_meta. FlipsisPrivateContextGraph(cgId)totrue.localOnly: false.Case 3: explicit
accessPolicy: 1AND agent-address allowlist, peer-ID allowlist emptySame as Case 2 plus the explicit policy. Same outcome — gossip leaks because no
DKG_ALLOWED_PEERis set.Root cause (code references)
The SWM gossip handler:
Allowlist resolver — queries only
DKG_ALLOWED_PEER:nullfrom this helper becomes the "admit every peer" sentinel at line 139, regardless of whetheraccessPolicy: "private"is set orDKG_ALLOWED_AGENTis populated.Meanwhile, the same allowlist concepts ARE honored elsewhere:
isPrivateContextGraph(cgId)— returnstrueif either explicitDKG_ACCESS_POLICY: "private"OR any allowlist entry (peer/agent/participant) exists (packages/agent/src/dkg-agent.ts:6262-6301).publishPolicyis derived fromisPrivateContextGraph()and locked in at registration time (dkg-agent.ts:4114-4121).canReadContextGraph()(dkg-agent.ts:3317).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:
inviteAgentToContextGraph(cgId, agentAddress)(dkg-agent.ts:4368-4430)DKG_ALLOWED_AGENTinviteToContextGraph(cgId, peerId)(dkg-agent.ts:4290-4340)DKG_ALLOWED_PEERBoth 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
accessPolicyactually constrains todayFor completeness —
accessPolicy: 1(curated mode) currently affects:publishPolicy): determines whether VM publish goes through the curated or open contract path.packages/publisher/src/access-handler.ts:86-110).What this is NOT
accessPolicylives in node-local_meta/ ontology graph, not on-chain (smart contract has no allowlist concept; it tracks onlypublishPolicy).inviteAgentToContextGraphor passaccessPolicy: 1and observeisPrivateContextGraph()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)
Have the gossip handler consult
isPrivateContextGraph(). When the function returnstruebutDKG_ALLOWED_PEERis empty, the handler must NOT default to "admit all" — it must either:Reconcile the two allowlists. Either:
a. Make
inviteAgentToContextGraph()also writeDKG_ALLOWED_PEERtriples, requiring the operator to register a peer ID alongside the agent address.b. Teach
getContextGraphAllowedPeers()to also resolveDKG_ALLOWED_AGENTto peer IDs at query time, requiring a canonical agent→peer registry.c. Drop
DKG_ALLOWED_AGENTentirely and requireDKG_ALLOWED_PEERfor all invitations. Simplest but loses the abstraction the agent layer offers.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.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.
Short-term mitigation: Refuse SWM gossip publish when
isPrivateContextGraph(cgId) === truebut the gossip handler cannot prove the recipient set is bounded (i.e.DKG_ALLOWED_PEERis 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
isPrivateContextGraph(cgId) === true, a peer outside the effective allowlist must NOT receive plaintext SWM gossip. This must hold for ALL of: explicitaccessPolicy: 1with empty allowlist,DKG_ALLOWED_AGENTpopulated with emptyDKG_ALLOWED_PEER, and combinations of the two.isPrivateContextGraph(cgId)returning true must imply effective gating on the gossip path, not just on metadata helpers.DKG_ALLOWED_AGENTmust (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.isPrivateContextGraphtrue) but gossips openly. Either the configuration is rejected at create/invite time, or the gossip handler enforces correctly without operator intervention.Out of scope
publishPolicyafter registration (already documented as immutable).Related code
packages/publisher/src/workspace-handler.ts:119-324— SWM gossip handler + allowlist helperpackages/agent/src/dkg-agent.ts:4290-4430— both invitation flows side by sidepackages/agent/src/dkg-agent.ts:6254-6304—isPrivateContextGraph()definition (explicit policy + allowlist fallback)packages/agent/src/dkg-agent.ts:5032-5036— agent-side helper that mirrors the gossip handler's filterpackages/publisher/src/access-handler.ts:86-110—/dkg/access/1.0.0per-request authorization (peer-list-based)