Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/agent/src/dkg-agent-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,11 @@ export class WorkspaceCryptoMethods extends DKGAgentBase {
for (const record of this.localAgents.values()) {
if (!record.privateKey) continue;
const signingRecord = { ...record, privateKey: record.privateKey };
if (defaultAddress && record.agentAddress.toLowerCase() === defaultAddress) {
// GH #787 — a node-level key record can carry a privateKey but no
// agentAddress (operational identity, not an agent). Guard the compare so
// it falls through to the fallback signer instead of throwing TypeError
// (`toLowerCase` of undefined → HTTP 500 on every SWM write via that token).
if (defaultAddress && record.agentAddress?.toLowerCase() === defaultAddress) {
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
return signingRecord;
}
fallback ??= signingRecord;
Comment thread
Bojan131 marked this conversation as resolved.
Expand Down
54 changes: 53 additions & 1 deletion packages/agent/src/dkg-agent-swm-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,33 @@ export class SwmHostModeMethods extends DKGAgentBase {
}
}

/**
* GH #1124 — DEFINITIVE public-CG check for the host-mode ingest gates. Unlike
* `!isCuratedForHostMode` (which treats UNKNOWN as public), this returns true
* ONLY for a CG we can positively confirm is public (open). Curated AND unknown
* both return false, so an in-flight chain-event race (policy not loaded yet)
* keeps the conservative ciphertext+allowlist gates and heals via member
* catchup — it can NEVER misclassify a curated CG as public and admit an
* unauthenticated plaintext envelope into curated storage.
*/
async isConfirmedPublicForHostMode(this: DKGAgent, contextGraphId: string): Promise<boolean> {
const sub = this.subscribedContextGraphs.get(contextGraphId);
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
// Any curated marker is disqualifying.
if (sub?.onChainHash) return false;
if (sub?.onChainId) {
const cached = this.onChainAccessPolicyCache.get(sub.onChainId);
if (cached === 1) return false;
if (cached === 0) return true; // confirmed public on-chain
}
// Local `_meta` explicit accessPolicy is the authoritative tri-state:
// 'public' → yes, 'private' → no, null (unknown) → no (safe default).
try {
return (await this.getExplicitAccessPolicy(contextGraphId)) === 'public';
} catch {
return false;
}
}

/**
* Register the host-mode gossip handler for `contextGraphId` and
* track its reference so {@link unwireSwmHostModeHandler} can
Expand Down Expand Up @@ -1009,7 +1036,13 @@ export class SwmHostModeMethods extends DKGAgentBase {
isCiphertext = skm.type === SWM_SENDER_KEY_MESSAGE_TYPE;
} catch { /* fall through */ }
}
if (!isCiphertext) return;
// GH #1124 — a curated CG MUST carry ciphertext, so a non-ciphertext
// envelope there is garbage → drop early (unchanged). A CONFIRMED-public
// (open) CG legitimately gossips PLAINTEXT SWM, so let it through to the
// authority + storage path below. Unknown CGs stay on the drop path (safe;
// member catchup heals once the policy resolves) — see
// isConfirmedPublicForHostMode for why UNKNOWN is treated as not-public.
if (!isCiphertext && !(await this.isConfirmedPublicForHostMode(storageCgId))) return;

// Authority check: verify the envelope signature against the
// curated CG's agent allowlist. Without this, a topic-reachable
Expand All @@ -1024,6 +1057,24 @@ export class SwmHostModeMethods extends DKGAgentBase {
const handler = this.getOrCreateSharedMemoryHandler();
const verdict = await handler.verifyHostModeEnvelopeAuthority(data, storageCgId, fromPeerId);
if (!verdict.accepted) {
// GH #1124 — a CONFIRMED-public (open) CG has no curated agent allowlist,
// so the authority check returns 'no agent allowlist'. Accept it for such
// CGs: the envelope is already confirmed SIGNED (verifyHostModeEnvelopeAuthority
// rejects unsigned envelopes BEFORE the allowlist check), and public-CG
// host-mode storage is bounded by the per-CG byte cap + registration
// economics (the same safety net the pre-reg fail-open below relies on).
// Scoped to isConfirmedPublicForHostMode so a curated CG mid chain-event
// race (also 'no agent allowlist') is NEVER admitted — it stays a drop and
// heals via member catchup. Every OTHER rejection reason (decode failure,
// unsigned, sig mismatch, peer-not-in-allowlist) still drops for ALL CGs.
const noAllowlist = verdict.reason === 'no agent allowlist on context graph';
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
if (noAllowlist && (await this.isConfirmedPublicForHostMode(storageCgId))) {
this.log.debug(
ctx,
`Host-mode accepting public-CG plaintext SWM cg=${storageCgId} from=${fromPeerId} (#1124; open CG has no curated allowlist, envelope is signed, per-CG byte cap remains the safety net)`,
);
// fall through to the rate-limit + store path below
} else {
// "no agent allowlist" is the expected outcome during the brief
// chain-event race window (cores see the beacon, auto-engage
// host-mode, then receive ciphertext BEFORE the
Expand All @@ -1048,6 +1099,7 @@ export class SwmHostModeMethods extends DKGAgentBase {
);
}
return;
}
}

// OT-RFC-38 / LU-6 Phase B — pre-registration ciphertext rate-
Expand Down
98 changes: 98 additions & 0 deletions packages/agent/test/swm/host-mode-public-ingest-1124.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* GH #1124 — public context graphs must be able to publish to Verifiable Memory.
*
* Host-mode cores dropped a PUBLIC CG's plaintext SWM share at two gates in
* `ingestSwmHostModeEnvelope` (the `isCiphertext` sniff + the curated-agent
* authority check), so a public CG's storage-ACK quorum was unreachable on a
* host-mode sharded topology. The fix opens BOTH gates — but ONLY for a CG that
* can be positively confirmed public via `isConfirmedPublicForHostMode`.
*
* The SECURITY-CRITICAL property is that helper's bias: a curated CG (including
* one whose on-chain policy hasn't loaded yet — the chain-event race) must NEVER
* be misclassified as public, because that would admit an unauthenticated
* plaintext envelope into curated storage. This pins: confirmed-public → true;
* curated marker → false; UNKNOWN → false (safe default, heals via catchup).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { ethers } from 'ethers';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DKGAgent } from '../../src/index.js';

interface Internals {
isConfirmedPublicForHostMode(cgId: string): Promise<boolean>;
subscribedContextGraphs: Map<string, { subscribed: boolean; synced?: boolean; onChainHash?: string; onChainId?: string }>;
onChainAccessPolicyCache: Map<string, number>;
getExplicitAccessPolicy(cgId: string, opts?: unknown): Promise<'public' | 'private' | null>;
}

describe('GH #1124 — isConfirmedPublicForHostMode safety bias (curated/unknown are NEVER public)', () => {
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
const tempDirs: string[] = [];
const agents: DKGAgent[] = [];
afterEach(async () => {
await Promise.all(agents.splice(0).map((a) => a.stop().catch(() => {}).then(() => a.store.close().catch(() => {}))));
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});

async function makeCore(): Promise<DKGAgent> {
const dataDir = await mkdtemp(join(tmpdir(), 'dkg-1124-'));
tempDirs.push(dataDir);
const core = await DKGAgent.create({ name: 'Pub1124Core', listenHost: '127.0.0.1', dataDir, nodeRole: 'core' });
agents.push(core);
return core;
}

it('curated via onChainHash → NOT public (even if _meta would say public)', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-curated-hash';
g.subscribedContextGraphs.set(cg, { subscribed: true, onChainHash: ethers.keccak256(ethers.toUtf8Bytes(cg)) });
g.getExplicitAccessPolicy = async () => 'public'; // must NOT override the curated marker
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false);
});

it('curated via on-chain accessPolicy cache === 1 → NOT public', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-curated-cache';
g.subscribedContextGraphs.set(cg, { subscribed: true, onChainId: '4242' });
g.onChainAccessPolicyCache.set('4242', 1);
g.getExplicitAccessPolicy = async () => 'public';
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false);
});

it('confirmed public via on-chain accessPolicy cache === 0 → public', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-public-cache';
g.subscribedContextGraphs.set(cg, { subscribed: true, onChainId: '5151' });
g.onChainAccessPolicyCache.set('5151', 0);
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(true);
});

it('confirmed public via explicit _meta accessPolicy "public" → public', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-public-meta';
g.getExplicitAccessPolicy = async () => 'public';
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(true);
});

it('explicit _meta accessPolicy "private" → NOT public', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-private-meta';
g.getExplicitAccessPolicy = async () => 'private';
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false);
});

it('UNKNOWN policy (null — chain-event race) → NOT public (safe default; the misclassification guard)', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-unknown';
g.getExplicitAccessPolicy = async () => null; // policy not loaded yet
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false);
});

it('policy lookup THROWS → NOT public (fail-safe)', async () => {
const g = (await makeCore()) as unknown as Internals;
const cg = 'cg-throws';
g.getExplicitAccessPolicy = async () => { throw new Error('chain unavailable'); };
expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false);
});
});
20 changes: 20 additions & 0 deletions packages/cli/src/daemon/http-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ export function isPublishQuad(value: unknown): value is PublishQuad {
);
}

/**
* GH #306 / #787 — shape guard for the WRITE routes (wm/write,
* shared-memory/write). Unlike {@link isPublishQuad} the `graph` term is
* OPTIONAL here: those routes legitimately accept `{subject,predicate,object}`
* and fill the graph internally. Without this guard, a string-shaped quad
* (e.g. an N-Quad line `"<s> <p> <o> ."`) slips past a bare `Array.isArray`
* check and crashes the agent write path with a TypeError → HTTP 500 instead
* of an actionable 4xx.
*/
export function isWritableQuad(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const v = value as Record<string, unknown>;
return (
typeof v.subject === "string" &&
typeof v.predicate === "string" &&
typeof v.object === "string" &&
(v.graph === undefined || typeof v.graph === "string")
);
}

function validatePublishQuadObjectTerms(
label: string,
quads: PublishQuad[],
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/daemon/routes/knowledge-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
validateOptionalSubGraphName,
validateRequiredContextGraphId,
parsePublishRequestBody,
isWritableQuad,
normalizeContextGraphIdOrUri,
resolveRequiredWriteContextGraphId,
} from "../http-utils.js";
Expand Down Expand Up @@ -938,6 +939,11 @@ export async function handleKnowledgeAssetsRoutes(ctx: RequestContext): Promise<
if (layer === "wm") {
if (verb === "write") {
if (!Array.isArray(parsed.quads)) return jsonResponse(res, 400, { error: 'Missing "quads"' });
// GH #306 — reject string-shaped / malformed quads here (4xx) instead of
// letting them crash the agent write path with a TypeError (HTTP 500).
if (!parsed.quads.every(isWritableQuad)) {
return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' });
}
// A bare write to a name that was never created used to fall through to
// the legacy `/assertion/{addr}/{name}` graph and produce a KA that is
// permanently 404 in the descriptor API (no `_meta` lifecycle record,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/daemon/routes/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import {
import {
resolveNameToPeerId,
isPublishQuad,
isWritableQuad,
parsePublishRequestBody,
jsonResponse,
safeDecodeURIComponent,
Expand Down Expand Up @@ -1641,6 +1642,10 @@ WHERE {
const contextGraphId = parsed.contextGraphId;
if (!quads?.length)
return jsonResponse(res, 400, { error: 'Missing "quads"' });
// GH #787 / #306 — reject string-shaped / malformed quads here (4xx) instead
// of crashing the SWM write path with a TypeError (HTTP 500).
if (!Array.isArray(quads) || !quads.every(isWritableQuad))
return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' });
const resolvedContextGraphId = await resolveRequiredWriteContextGraphId(
agent,
contextGraphId,
Expand Down Expand Up @@ -2210,6 +2215,9 @@ WHERE {
const contextGraphId = parsed.contextGraphId;
if (!quads?.length)
return jsonResponse(res, 400, { error: 'Missing "quads"' });
// GH #787 / #306 — reject string-shaped / malformed quads (4xx, not a 500 crash).
if (!Array.isArray(quads) || !quads.every(isWritableQuad))
return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' });
const resolvedContextGraphId = await resolveRequiredWriteContextGraphId(
agent,
contextGraphId,
Expand Down
Loading
Loading