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 c7ec449c1..8ecde46ce 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -445,6 +445,53 @@ export async function handleMemoryRoutes(ctx: RequestContext): Promise { '"subGraphName" and "publishContextGraphId" cannot be used together', }); } + + // 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. + // + // 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( + 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,