fix(swm): accept plaintext SWM on public-on-chain agent-gated context graphs - #1843
fix(swm): accept plaintext SWM on public-on-chain agent-gated context graphs#1843branarakic wants to merge 4 commits into
Conversation
… graphs Sender and receiver decided the SWM encryption requirement from different authorities, and disagreed on exactly one set: accessPolicy=0 AND agent-gated — the public/curated cell. SENDER (packages/agent, resolveWorkspaceRecipientsGated) short-circuits on a LIVE on-chain accessPolicy of 0 and gossips PLAINTEXT, deliberately ignoring the agent gate. That is correct and deliberate: on a public CG the allowlist governs PUBLISH AUTHORITY, not READ ACCESS, so there is nothing to keep confidential. Encrypting instead bootstraps a sender-key handshake that non-gated recipients reject, which previously surfaced as HTTP 500 on promote — the bug packages/agent/test/swm-public-cg-plaintext.test.ts exists to prevent. RECEIVER (workspace-handler) required encryption whenever agentGateAddresses was non-null, derived from local allowedAgent/participantAgent triples — intent, not authority. It therefore dropped the sender's plaintext with retryable:false while the sender reported success, so every member->curator SWM share on a public/curated CG failed permanently and silently. Reproduced on a 6-node devnet: member share returns status=swm-shared, curator logs "Sender Key encrypted workspace payload required ... (permanent rejection)", content never converges, and the curator's subsequent vm/publish 409s as not finalized. FIX: the receiver now consults the SAME live on-chain predicate the sender uses, injected as publicAccessPolicyOnChainOracle alongside the existing chainAgentGateOracle and wired to isContextGraphPublicOnChain. An agent gate forces encryption UNLESS the CG is proven public on-chain. Fail-closed throughout — absent oracle, false, or a throw all mean "not proven public" and keep the requirement, so a stale mapping or RPC flake can never become a plaintext-acceptance hole. A sender-side fix was tried first and reverted: it broke 2 of 38 tests in swm-public-cg-plaintext.test.ts, whose assertion `store.query.calls == []` requires the public path not to touch the store at all. TEST COVERAGE. New packages/publisher/test/swm-public-gated-plaintext-accept.test.ts covers public+gated accept, gated-not-public require, probe-throws fail-closed, no-oracle fail-closed, and ungated unaffected. It was MUTATION-TESTED: restoring the pre-fix behaviour makes the public+gated case fail. CI GAP CLOSED. Both vitest.unit.config.ts files use explicit include lists, and neither the new test NOR swm-public-cg-plaintext.test.ts was listed — the latter guards a previously-fixed production bug and was not running in CI at all. Both added: publisher 37->38 files / 422->427 tests, agent 90->91 / 1132->1170. Mixed-fleet note: un-upgraded receivers keep rejecting plaintext on agent-gated public CGs until they take this build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lupuszr
left a comment
There was a problem hiding this comment.
Requesting changes on the existing encrypted-wire compatibility blocker. I reproduced it through the real SharedMemoryHandler.handle() path on this head: with an agent gate, publicAccessPolicyOnChainOracle returning true, and a valid signed encrypted payload (the sender's legitimate fail-closed result during oracle or RPC skew), the handler returns applied=false and logs that encrypted workspace payload is only supported for private or agent-gated graphs. Public proof should allow plaintext; it must not forbid the safer encrypted format for the same gated CG. The added five tests still do not exercise handle(), so they all pass while this real regression fails.
…ed unit
Review found the first cut of this fix inverted the failure instead of
removing it: driving requiresEncryptedPayload to false for a public+agent-gated
CG also flipped the later validation from "encrypted required" to "encrypted
not supported", so any ENCRYPTED write for the same CG was rejected with
retryable:false. Sender and receiver chain probes legitimately disagree during
RPC flakes, stale mappings, rollout skew, or older clients — and a sender that
cannot prove public FAILS CLOSED and encrypts, which this receiver would then
permanently drop: the mirror image of the plaintext drop being fixed.
The two questions are now separate, in one exported decision the handler
itself consumes (resolveWorkspaceEncryptionRequirement):
requiresEncryptedPayload MUST it be encrypted? private, or gated and not
proven public on-chain (policy, chain-derived)
supportsEncryptedPayload MAY it be encrypted? private, or gated at all
(structure, local)
Both encodings are admitted during any skew window; an ungated public CG
still refuses Sender-Key payloads exactly as before. Applied to BOTH encoding
branches — the review flagged the senderKeyMessage arm, and the same flaw was
in the encryptedPayload arm.
Also from review: the regression test mirrored the fix instead of exercising
it — it reimplemented the boolean against a stubbed private helper, so
reverting the production decision would have kept it green. The decision is
now an exported function precisely so the test covers shipped logic; the suite
asserts the public/curated accept, the fail-closed unproven case, that a
public proof never downgrades a PRIVATE CG, the unchanged ungated behaviour,
and the skew invariant (never REQUIRE without SUPPORT) across all eight input
combinations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both findings addressed in 6d251e6. Public proof makes encrypted public-gated writes permanently fail (RED) — correct, and the first cut of this fix had inverted the failure rather than removed it. The two questions are now separate: Regression test mirrors the fix instead of exercising the receiver (RED) — the decision is now an exported unit, 🤖 Generated with Claude Code |
Adversarial review —
|
…ated CGs
The must-vs-may refactor turned the probe unconditional: every SWM gossip
receive awaited isContextGraphProvenPublicOnChain (a live chain read through
resolveOnChainAccessPolicyState with its timeout machinery) even when the CG
has no agent gate — the case where resolveWorkspaceEncryptionRequirement never
reads the answer, because both requires and supports collapse to
hasPrivateAccessPolicy.
Measured on a 6-node devnet, that took SWM receive-apply from ~3ms to ~33ms
and turned the public-CG sync gate red in BOTH public cells: the author's next
convergence poll slipped one 3s cycle, the VM publish therefore landed AFTER
the live receiver's subscribe-triggered catch-up had already run, and — since
chain-promote defers without transaction provenance and the snapshot
materializer lane is separately parser-blocked ("ambiguous publishedAt") — the
live receiver starved until the ~5-minute periodic sweep. Symptom in the gate:
"LIVE receiver has VM content — TIMEOUT ... reconcile pending watermark=0/1"
at 150s, while the LATE receiver (fresh catch-up, runs post-publish by
construction) converged in ~3s.
Restore the pre-refactor evaluation order — probe only when
agentGateAddresses !== null — while keeping the must-vs-may split intact.
For a gated CG behaviour is unchanged.
Tests drive the REAL handler with a counting oracle, in the unit include list:
- ungated CG: write applies AND the probe is never invoked. Mutation-tested:
re-introducing the unconditional await fails exactly this test.
- gated CG: the probe IS invoked and a public proof admits plaintext, so
laziness cannot regress into "never".
Note: the underlying exposure is pre-existing and NOT closed here — a LIVE
subscriber whose catch-up ran before the publish has no event-driven VM
delivery lane (finalization broadcast schedules no durable sync; chain-promote
requires provenance it never gets; the SWM materializer heal lane dies on
"ambiguous publishedAt" fleet-wide). The baseline passes the gate only because
its receive path is fast enough that the publish beats the queued catch-up.
Deserves its own issue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
New commit 9894759 pushed for review: evaluate the on-chain public probe lazily, only for agent-gated CGs. The earlier integration awaited |
…the agent-to-handler oracle wiring Two review follow-ups on #1843: - resolveWorkspaceEncryptionRequirement (and its must-vs-may rationale) moves from the 2.3k-line workspace-handler.ts into workspace-encryption-policy.ts; the handler keeps only the local lazy-probe sequencing note, and the policy tests import the focused module instead of the orchestration file. - New agent-level regression builds SharedMemoryHandler through the REAL getOrCreateSharedMemoryHandler and delivers a signed plaintext write on an agent-gated CG with a counting isContextGraphPublicOnChain: the write must apply and the probe must be consulted. Removing the publicAccessPolicyOnChainOracle wiring from dkg-agent-swm-substrate.ts fails this test with the exact production drop it prevents (mutation-verified). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the agent-to-handler oracle wiring Two review follow-ups on #1843: - resolveWorkspaceEncryptionRequirement (and its must-vs-may rationale) moves from the 2.3k-line workspace-handler.ts into workspace-encryption-policy.ts; the handler keeps only the local lazy-probe sequencing note, and the policy tests import the focused module instead of the orchestration file. - New agent-level regression builds SharedMemoryHandler through the REAL getOrCreateSharedMemoryHandler and delivers a signed plaintext write on an agent-gated CG with a counting isContextGraphPublicOnChain: the write must apply and the probe must be consulted. Removing the publicAccessPolicyOnChainOracle wiring from dkg-agent-swm-substrate.ts fails this test with the exact production drop it prevents (mutation-verified). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| supportsEncryptedPayload: boolean; | ||
| } | ||
|
|
||
| export function resolveWorkspaceEncryptionRequirement( |
There was a problem hiding this comment.
🟡 Issue: The “canonical” encryption policy is still receiver-only
What's wrong
The PR says it keeps both sides of the wire on one authority, but structurally the policy is still duplicated: the receiver calls this new helper while the sender keeps its own inline branch. That leaves the invariant protected by comments and tests rather than by one shared abstraction.
Example
If a future access-policy state changes when plaintext is allowed, a maintainer has to update both resolveWorkspaceRecipientsGated and resolveWorkspaceEncryptionRequirement; updating only the new helper would reintroduce sender/receiver drift.
Suggested direction
Make this module own the common policy decision used by both sender and receiver, or stop presenting it as canonical and keep it explicitly receiver-local.
For Agents
Unify the shared must-encrypt/may-encrypt decision between workspace-encryption-policy.ts, DKGAgent.resolveWorkspaceRecipientsGated, and the handler receive path. Preserve public-on-chain agent-gated plaintext on both send and receive, with recipient-key lookup layered separately on the sender side.
|
|
||
| export interface WorkspaceEncryptionPolicyInput { | ||
| readonly hasPrivateAccessPolicy: boolean; | ||
| readonly agentGateAddresses: readonly string[] | null; |
There was a problem hiding this comment.
🟡 Issue: The policy helper leaks the nullable allowlist representation
What's wrong
The helper’s contract suggests address contents matter, but the implementation only cares about nullness. That bakes a handler sentinel into a supposedly canonical policy API and forces every caller to remember that [] is materially different from null.
Example
agentGateAddresses: [] and agentGateAddresses: ['0x...'] both mean “agent-gated/supports encryption”, while null means “ungated”. A future caller that normalizes an empty gate to null changes policy without touching the policy code.
Suggested direction
Pass explicit facts such as hasAgentGate or a discriminated agentGate model instead of the raw address array, and consider a small handler-level resolver that owns the lazy public probe before calling the pure policy function.
For Agents
Refactor WorkspaceEncryptionPolicyInput and the call in workspace-handler.ts around the policy resolution. Preserve the fully-revoked gate distinction from getContextGraphAgentGateAddresses by mapping it explicitly to hasAgentGate: true.
| GOSSIP_ENVELOPE_VERSION, | ||
| GOSSIP_TYPE_WORKSPACE_PUBLISH, | ||
| } from '@origintrail-official/dkg-core'; | ||
| import { DKGAgent } from '../src/index.js'; |
There was a problem hiding this comment.
💡 Suggestion: Keep the wiring test off publisher test internals
Why it matters
This makes the agent package’s test depend on another package’s private test layout and on DKGAgent internals, so routine refactors can break the test even when the production wiring behavior is unchanged.
Suggestion
Promote the rootless request encoder to a shared test utility or build the payload through exported protocol APIs, and prefer a typed test harness or mock chain adapter over casting the agent to private shapes.
|
Both remaining review items addressed in Extract the SWM encryption policy (:297) — No test verifies the agent-to-handler oracle wiring (:926) — new Same module move cherry-picked cleanly onto the #1852 canary branch ( |
|
Superseded: this work shipped to main in the v10.0.9 promote (#1888) and is published as |
The bug
On a public/curated Context Graph, a member's SWM share is permanently dropped by the receiver. The share reports success to the sender (
status: swm-shared) while the content silently goes nowhere, somember shares → curator publishes— the headline curated-CG flow — cannot complete.Receiver log:
Root cause: sender and receiver used different authorities
accessPolicy === 0→ plaintext, agent gate never consulteddkg-agent-crypto.tsresolveWorkspaceRecipientsGatedhasPrivateAccessPolicy || agentGateAddresses !== null→ require encryptionworkspace-handler.tsThey disagree on exactly one set:
accessPolicy = 0AND agent-gated — which is the public/curated cell (creating a curated CG stamps the curator intoallowedAgents). Sender emits plaintext, receiver demands encryption, write dropped withretryable: false.Why the receiver is the side that was wrong
The sender's plaintext choice is deliberate and correct. From
swm-public-cg-plaintext.test.ts:That test file was itself the fix for a production bug: a public CG carrying
DKG_ALLOWED_AGENTtook the encrypted path, bootstrapped a sender-key handshake that non-gated recipients rejected, and surfaced as HTTP 500 on promote. Encrypting a public CG protects nothing — the content is readable by policy — while reintroducing that failure.The receiver, by contrast, derived its requirement from local
allowedAgent/participantAgenttriples — intent, not authority — which is precisely what the sender's own comment warns against relying on.A sender-side fix was attempted first and reverted: it broke 2 of 38 tests in
swm-public-cg-plaintext.test.ts, whose assertionstore.query.calls == []requires the public path not to touch the store at all.The fix
The receiver now consults the same live on-chain predicate the sender uses, injected as
publicAccessPolicyOnChainOraclealongside the existingchainAgentGateOracleand wired toisContextGraphPublicOnChain. An agent gate forces encryption unless the CG is proven public on-chain.Fail-closed throughout: absent oracle,
false, or a throw all mean "not proven public" and keep the encryption requirement. A stale mapping or RPC flake can never become a plaintext-acceptance hole.Reviewer ask: this relaxes a security check. It is safe only because "public on-chain" means the content has no confidentiality requirement at all. Please confirm that reasoning holds.
Test coverage
New
packages/publisher/test/swm-public-gated-plaintext-accept.test.ts— 5 cases: public+gated accepts, gated-not-public requires, probe-throws fails closed, no-oracle fails closed, ungated unaffected.Mutation-tested: restoring the pre-fix behaviour makes the public+gated case fail. It invokes the real private probe off the prototype rather than reimplementing it.
CI gap closed
Both
vitest.unit.config.tsfiles use explicit include lists, and neither the new test norswm-public-cg-plaintext.test.tswas listed — the latter guards a previously-fixed production bug and was not running in CI at all. It only caught the wrong first fix because it was run by hand. Both added.Publisher 422 → 424, agent 1132 → 1143 on a pure
mainbase.Verification
Devnet, 6 nodes: member share now reaches the curator (
curator node received the member-shared asset — 2 quads, previously "not visible after 90228ms"). Curated authorization still holds — non-curator SWM gossip refused, non-curator VM publish refused (409).Mixed-fleet note
Un-upgraded receivers keep rejecting plaintext on agent-gated public CGs until they take this build.
🤖 Generated with Claude Code