From 6cde48eafacd6a8c0fdffbe1054ac172baf8f2ae Mon Sep 17 00:00:00 2001 From: Branimir Rakic <33914812+branarakic@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:18:56 +0200 Subject: [PATCH 1/3] fix(cli): gate /api/shared-memory/publish on curator identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §2.2 says only the CG's registered curator may promote SWM to Verified Memory, but the daemon had no caller-level auth check on `/api/shared-memory/publish` (or the legacy `/api/workspace/enshrine` alias). Non-curator callers reached the publisher, hit on-chain `UnauthorizedPublisher` revert, and got HTTP 200 with `status=tentative` — masking the authorization failure and leaving phantom tentative metadata on disk. Add the same owner-check the project-manifest-publish route already uses (`agent.assertContextGraphOwner`), mapping the thrown error to 403 (not the owner) or 400 (CG has no registered owner yet). The helper is the single source of truth for "caller is curator" across the daemon, so the semantics here stay in lockstep with share, invite, rename, and manifest-publish gates. Fixes devnet-test §4e ("Non-curator publish should be rejected with 4xx, got HTTP 200"). Made-with: Cursor --- packages/cli/src/daemon/routes/memory.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index c7ec449c1..ab5371363 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -445,6 +445,28 @@ export async function handleMemoryRoutes(ctx: RequestContext): Promise { '"subGraphName" and "publishContextGraphId" cannot be used together', }); } + + // Spec §2.2 — only the CG's registered curator may promote SWM to VM. + // Without this gate, any authenticated caller (including non-curator + // peers who legitimately write to SWM) could trigger an on-chain + // publish attempt that reverts late in the stack and returns HTTP 200 + // with `status=tentative` — masking the authorization failure and + // leaving a trail of phantom "tentative" metadata on disk. Reject + // up-front with a clean 4xx so callers get an explicit, + // spec-conformant rejection (matches the project-manifest-publish + // gate in context-graph.ts). + try { + await agent.assertContextGraphOwner( + paranetId, + requestAgentAddress, + "publish shared memory to Verified Memory", + ); + } catch (authErr: unknown) { + const msg = authErr instanceof Error ? authErr.message : String(authErr); + const code = /has no registered owner/.test(msg) ? 400 : 403; + return jsonResponse(res, code, { error: msg }); + } + const ctx = createOperationContext("publishFromSWM"); tracker.start(ctx, { contextGraphId: paranetId, From c731d6e73a6590f9a0b463ffc54ac5d6be668684 Mon Sep 17 00:00:00 2001 From: Branimir Rakic <33914812+branarakic@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:27:07 +0200 Subject: [PATCH 2/3] fix(cli): only gate curated CGs; let open CGs fall through to chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on #299 flagged that the preflight `assertContextGraphOwner` rejected every non-curator publish, including legitimate publishes to open context graphs where `ContextGraphs.isAuthorizedPublisher` accepts any non-zero collaborator. Make the HTTP gate policy-aware: - `DKGAgent.isContextGraphCurated(cgId)` exposes the existing private `isPrivateContextGraph` check (invite-only allowlist → curated). - `handleMemoryRoutes` only preflights the owner check when the CG is curated; open CGs proceed to the publisher and let the chain adapter arbitrate (which is what §2.2 of the publish spec describes). - Extend the EVM-policy-mapping unit test to pin `isContextGraphCurated` against the three publish-policy variants it already constructs, so this can't silently drift from the on-chain gate again. Devnet §4e (non-curator → 4xx) still holds because curated CGs are still gated; open-CG collaborator publishes (e.g. §4a-d) no longer hit a spurious 403. Made-with: Cursor --- packages/agent/src/dkg-agent.ts | 17 ++++++++ packages/agent/test/agent.test.ts | 12 ++++++ packages/cli/src/daemon/routes/memory.ts | 49 +++++++++++++++--------- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index dd825bda1..95cda600f 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -6013,6 +6013,23 @@ export class DKGAgent { } } + /** + * Public check for whether a CG is curated (private) vs open. + * + * Curated CGs restrict VM publish to the registered curator (mirrors + * the on-chain `publishPolicy = EVM_PUBLISH_CURATED` configured by + * `registerContextGraph` when local access policy is "private" or an + * allowlist exists). Open CGs accept publish attempts from any + * collaborator and let the chain adapter's `isAuthorizedPublisher` + * surface arbitrate. + * + * HTTP routes use this to decide whether an owner-only preflight is + * appropriate before handing off to the publisher. + */ + async isContextGraphCurated(contextGraphId: string): Promise { + return this.isPrivateContextGraph(contextGraphId); + } + /** * Public owner-check used by HTTP routes that need to gate curator-only * actions (manifest publish, SWM template rewrites, etc.). Throws a diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 9f2a5bb1c..4096b81ff 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1358,6 +1358,18 @@ decisions: [] expect(chain.createOnChainContextGraphCalls[2]?.publishAuthority).toBe(ethers.getAddress(chain.signerAddress)); expect(chain.createOnChainContextGraphCalls[2]?.participantAgents).toEqual([]); + // `isContextGraphCurated` must mirror the EVM publish-policy + // mapping above — HTTP routes (see + // packages/cli/src/daemon/routes/memory.ts) rely on it to decide + // whether to preflight a curator-only owner gate before + // `publishFromSharedMemory`. If this ever drifts from the policy + // the chain adapter actually enforces, non-curator publishes to + // open CGs will be rejected with 403 even though the contract + // accepts them (Codex PR#299 review finding). + expect(await agent.isContextGraphCurated('register-open-policy')).toBe(false); + expect(await agent.isContextGraphCurated('register-curated-policy')).toBe(true); + expect(await agent.isContextGraphCurated('register-agent-allowlist-policy')).toBe(true); + await agent.stop().catch(() => {}); }); diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index ab5371363..c3a5a05e0 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -446,25 +446,36 @@ export async function handleMemoryRoutes(ctx: RequestContext): Promise { }); } - // Spec §2.2 — only the CG's registered curator may promote SWM to VM. - // Without this gate, any authenticated caller (including non-curator - // peers who legitimately write to SWM) could trigger an on-chain - // publish attempt that reverts late in the stack and returns HTTP 200 - // with `status=tentative` — masking the authorization failure and - // leaving a trail of phantom "tentative" metadata on disk. Reject - // up-front with a clean 4xx so callers get an explicit, - // spec-conformant rejection (matches the project-manifest-publish - // gate in context-graph.ts). - try { - await agent.assertContextGraphOwner( - paranetId, - requestAgentAddress, - "publish shared memory to Verified Memory", - ); - } catch (authErr: unknown) { - const msg = authErr instanceof Error ? authErr.message : String(authErr); - const code = /has no registered owner/.test(msg) ? 400 : 403; - return jsonResponse(res, code, { error: msg }); + // Policy-aware preflight (spec §2.2): + // + // - Curated CGs (on-chain `publishPolicy = EVM_PUBLISH_CURATED`, + // which `registerContextGraph` sets for private CGs or any CG + // with an allowlist): only the registered curator may VM-publish. + // Without this gate, non-curator callers hit the on-chain + // `isAuthorizedPublisher` revert deep in the stack and the HTTP + // surface returns 200 with `status=tentative` — masking the + // authorization failure and leaving phantom tentative metadata + // on disk. + // + // - Open CGs (on-chain `publishPolicy = EVM_PUBLISH_OPEN`): any + // non-zero collaborator may publish per the contract; we must + // NOT gate on curator identity here or we'd reject legitimate + // participant publishes with 403. + // + // Preflight applies only to the curated branch; open CGs fall + // through to the publisher and the chain adapter decides. + if (await agent.isContextGraphCurated(paranetId)) { + try { + await agent.assertContextGraphOwner( + paranetId, + requestAgentAddress, + "publish shared memory to Verified Memory", + ); + } catch (authErr: unknown) { + const msg = authErr instanceof Error ? authErr.message : String(authErr); + const code = /has no registered owner/.test(msg) ? 400 : 403; + return jsonResponse(res, code, { error: msg }); + } } const ctx = createOperationContext("publishFromSWM"); From 69a0dfde04cd99dcd7d3f1eb7b36df1623a90420 Mon Sep 17 00:00:00 2001 From: Branimir Rakic <33914812+branarakic@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:35:39 +0200 Subject: [PATCH 3/3] docs(memory): note PCA/delegation gap in curator preflight Codex PR#299 review flagged that assertContextGraphOwner is stricter than the contract's isAuthorizedPublisher for curated CGs: PCA delegation and post-NFT-transfer owners would be rejected with 403 here even though the chain accepts them. Chain-authoritative preflight is the right long-term fix but is cross-cutting (same helper is used by share/invite/rename/manifest-publish). Document the gap in-place so the next reader knows it's a known scope boundary, not a fresh regression. Made-with: Cursor --- packages/cli/src/daemon/routes/memory.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index c3a5a05e0..8ecde46ce 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -464,6 +464,20 @@ export async function handleMemoryRoutes(ctx: RequestContext): Promise { // // Preflight applies only to the curated branch; open CGs fall // through to the publisher and the chain adapter decides. + // + // Known scope gap (Codex PR#299 review, tracked separately): + // `assertContextGraphOwner` compares against the locally stored + // `dkg:curator` wallet DID. The on-chain + // `ContextGraphs.isAuthorizedPublisher` is richer — for PCA + // curators it live-resolves the NFT owner and any + // `agentToAccountId`-registered agents. This preflight therefore + // over-rejects PCA-delegated agents and post-transfer NFT holders + // whose wallets don't match the stale local curator metadata. The + // same shape of check is already used by share, invite, rename, + // and manifest-publish routes — migrating them all to a + // chain-authoritative preflight is a separate follow-up. Until + // then, those callers can work around this by publishing from the + // wallet recorded as the CG's local curator. if (await agent.isContextGraphCurated(paranetId)) { try { await agent.assertContextGraphOwner(