Skip to content

fix(swm): accept plaintext SWM on public-on-chain agent-gated context graphs - #1843

Closed
branarakic wants to merge 4 commits into
mainfrom
fix/swm-public-gated-plaintext
Closed

fix(swm): accept plaintext SWM on public-on-chain agent-gated context graphs#1843
branarakic wants to merge 4 commits into
mainfrom
fix/swm-public-gated-plaintext

Conversation

@branarakic

Copy link
Copy Markdown
Contributor

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, so member shares → curator publishes — the headline curated-CG flow — cannot complete.

Receiver log:

SWM write rejected: Sender Key encrypted workspace payload required for
private or agent-gated context graph "…"
SWM substrate receiver dropping share from 12D3KooW… (permanent rejection)

Root cause: sender and receiver used different authorities

condition source
sender accessPolicy === 0 → plaintext, agent gate never consulted dkg-agent-crypto.ts resolveWorkspaceRecipientsGated
receiver hasPrivateAccessPolicy || agentGateAddresses !== null → require encryption workspace-handler.ts

They disagree on exactly one set: accessPolicy = 0 AND agent-gated — which is the public/curated cell (creating a curated CG stamps the curator into allowedAgents). Sender emits plaintext, receiver demands encryption, write dropped with retryable: 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:

"On a public CG the allowedAgent list governs publish authority, not read access."

That test file was itself the fix for a production bug: a public CG carrying DKG_ALLOWED_AGENT took 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/participantAgent triples — 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 assertion store.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 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 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.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. It only caught the wrong first fix because it was run by hand. Both added.

Publisher 422 → 424, agent 1132 → 1143 on a pure main base.

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

… 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>
Comment thread packages/publisher/src/workspace-handler.ts Outdated
Comment thread packages/publisher/test/swm-public-gated-plaintext-accept.test.ts Outdated
Comment thread packages/publisher/src/workspace-handler.ts Outdated
Comment thread packages/publisher/test/swm-public-gated-plaintext-accept.test.ts Outdated

@lupuszr lupuszr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@branarakic

Copy link
Copy Markdown
Contributor Author

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: requiresEncryptedPayload (MUST it be encrypted — private, or gated-and-not-proven-public; chain-derived) vs supportsEncryptedPayload (MAY it be encrypted — private, or gated at all; structural). An agent-gated CG always accepts Sender-Key payloads even when proven public, so a sender whose chain probe flaked and failed closed into encryption is admitted rather than permanently dropped. Applied to both encoding branches — the review flagged the senderKeyMessage arm; the identical flaw was in the encryptedPayload arm. An ungated public CG still refuses encrypted payloads exactly as before.

Regression test mirrors the fix instead of exercising the receiver (RED) — the decision is now an exported unit, resolveWorkspaceEncryptionRequirement, and the handler consumes it, so the test covers shipped logic: reverting the production decision breaks the suite. This also lands the centralization the YELLOW thread asked for — send-side and receive-side policy now have one named home — and removes the cast-heavy private-helper stubbing the other YELLOW flagged. The suite asserts the public/curated accept, the fail-closed unproven case, that a public proof never downgrades a PRIVATE CG, unchanged ungated behaviour, and the skew invariant (never REQUIRE without SUPPORT) across all eight input combinations.

🤖 Generated with Claude Code

Comment thread packages/publisher/src/workspace-handler.ts Outdated
@Jurij89

Jurij89 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Adversarial review — fix(swm): accept plaintext SWM on public-on-chain agent-gated context graphs

Multi-lens review against the PR head (a4640d03d), with an explicit adversarial pass on the security question you flagged. Headline: the relaxation is safe. The two findings are a performance regression on the SWM write hot path and a test that doesn't actually guard the line it's meant to.


✅ The security relaxation is sound (your reviewer ask, confirmed)

You asked reviewers to confirm that accepting plaintext on a public-on-chain gated CG opens no hole. It does not, and the reason is worth stating explicitly: the encryption decision is independent of the authority check. The if (agentGateAddresses !== null) branch still runs verifyAgentEnvelope against the gate (workspace-handler.ts:~1007) before the new gateRequiresEncryption computation, so a non-gated sender's plaintext is still rejected on envelope verification — relaxing encryption never relaxes who may write. Adversarial attempts to (a) bypass the gate via the trustedReplay / public-host-catchup branch and (b) mis-prove a private CG as public (numeric-id reuse / stale local mapping) both failed: resolveOnChainAccessPolicyState rebinds a locally-resolved id to the on-chain name-hash before trusting it, and the whole chain fails closed on unknown/throw. Encrypting a public CG protects nothing, so accepting plaintext removes no confidentiality. 👍


🟡 MEDIUM — the on-chain oracle now runs on every agent-gated SWM write, including private/encrypted ones where its result is discarded

gateRequiresEncryption short-circuits on &&, so the oracle is invoked whenever agentGateAddresses !== null:

const gateRequiresEncryption = agentGateAddresses !== null
  && !(await this.isContextGraphProvenPublicOnChain(contextGraphId, ctx));
const requiresEncryptedPayload = hasPrivateAccessPolicy || gateRequiresEncryption;
if (requiresEncryptedPayload && !decoded.encryptedPayload && !decoded.senderKeyMessage) {  }

Two things compound here:

  1. isContextGraphProvenPublicOnChainresolveOnChainAccessPolicyStatereadLiveOnChainAccessPolicy deliberately reads fresh from chain and bypasses onChainAccessPolicyCache for this security decision (its own comment: "Always read the access policy FRESH from chain here"). That's 2–3 eth_calls per invocation (isContextGraphActiveOnChain + getContextGraphAccessPolicy, plus the identity-binding read), each bounded by CHAIN_POLICY_READ_TIMEOUT_MS.
  2. The result only matters when the payload is not encrypted (it only feeds the requiresEncryptedPayload && !decoded.encryptedPayload && !decoded.senderKeyMessage gate). On a private/gated CG every SWM write is encrypted, so the oracle runs and its answer is thrown away every time.

Net effect: every private-CG SWM write now issues 2–3 fresh chain reads it never needed, and its latency is coupled to RPC health — an RPC flake adds up to the read timeout to an SWM write that was always going to be accepted. Host-catchup replay of N aged envelopes multiplies this by N.

Fix (trivial): consult the oracle only when the payload is actually plaintext:

const payloadIsEncrypted = Boolean(decoded.encryptedPayload || decoded.senderKeyMessage);
const requiresEncryptedPayload = !payloadIsEncrypted && (
  hasPrivateAccessPolicy
  || (agentGateAddresses !== null && !(await this.isContextGraphProvenPublicOnChain(contextGraphId, ctx)))
);
if (requiresEncryptedPayload) {  }

This preserves the exact accept/reject semantics while removing the chain read from the common encrypted-write path entirely.


🟢 LOW — the regression test does not exercise the call site it's meant to guard

swm-public-gated-plaintext-accept.test.ts invokes the real isContextGraphProvenPublicOnChain off the prototype (good), but decide() reimplements the surrounding decision (agentGated && !provenPublic) and drops the hasPrivateAccessPolicy || disjunct — it never calls the workspace-handler.ts decision path. So the mutation that actually matters — reverting the call site to hasPrivateAccessPolicy || agentGateAddresses !== null — leaves all 5 tests green. The "mutation-tested: restoring the pre-fix behaviour makes the public+gated case fail" claim holds only for mutating the probe, not for reverting the line the PR changes. The regression guard has a blind spot exactly at that line.

Suggested: drive the assertion through the handler's real decision (even a thin seam that calls the actual requiresEncryptedPayload computation), so a revert of the call-site expression fails a test.


Method: independent lenses (perf / security-authority / test-quality) plus two adversarial verifiers per candidate re-reading the head code; the security "safe" conclusion is the result of skeptics failing to construct an exploit, and the perf finding was re-verified by hand against readLiveOnChainAccessPolicy.

…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>
@branarakic

Copy link
Copy Markdown
Contributor Author

New commit 9894759 pushed for review: evaluate the on-chain public probe lazily, only for agent-gated CGs. The earlier integration awaited isContextGraphProvenPublicOnChain unconditionally in handle(), putting a live chain RPC on the hot path of every SWM gossip receive — measured ~3ms → ~33ms per receive on a 6-node devnet, which made LIVE receivers miss VM/markdown convergence windows (caught by the devnet gate, not by unit tests). The probe is now only evaluated when the CG is agent-gated; ungated public receives never touch the chain. Handler-boundary tests with a counting oracle: ungated → probe never invoked (mutation-checked by re-introducing the unconditional await), gated → probe invoked. Same fix is staged for testnet-canary as #1852.

Comment thread packages/agent/src/dkg-agent-swm-substrate.ts
…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>
branarakic pushed a commit that referenced this pull request Jul 20, 2026
…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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@branarakic

Copy link
Copy Markdown
Contributor Author

Both remaining review items addressed in 0d08cb2ae:

Extract the SWM encryption policy (:297)resolveWorkspaceEncryptionRequirement and the canonical must-vs-may rationale now live in packages/publisher/src/workspace-encryption-policy.ts; workspace-handler.ts keeps only the local lazy-probe sequencing note, and the policy tests import the focused module. The duplicated policy narrative at the call site is collapsed into a pointer to the module.

No test verifies the agent-to-handler oracle wiring (:926) — new packages/agent/test/swm-plaintext-oracle-wiring.test.ts (in the explicit vitest include list) builds the handler through the REAL getOrCreateSharedMemoryHandler, installs a counting isContextGraphPublicOnChain on the agent, and delivers a signed plaintext write on an agent-gated CG: it must apply and the probe must be consulted. Mutation-verified: deleting the publicAccessPolicyOnChainOracle option from dkg-agent-swm-substrate.ts fails the test with the exact production drop ('Sender Key encrypted workspace payload required...'). Full agent unit suite green (1144 tests).

Same module move cherry-picked cleanly onto the #1852 canary branch (09f876581).

@branarakic

Copy link
Copy Markdown
Contributor Author

Superseded: this work shipped to main in the v10.0.9 promote (#1888) and is published as @origintrail-official/dkg@10.0.9. The commits reached main via testnet-canary rather than through this PR, so GitHub could not auto-close it. Closing to keep the review queue accurate — no content is lost.

@branarakic branarakic closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants