diff --git a/src/durable-message-guards.ts b/src/durable-message-guards.ts index 6edc001ce..a4a8ee3c7 100644 --- a/src/durable-message-guards.ts +++ b/src/durable-message-guards.ts @@ -44,6 +44,16 @@ // arbitrary user prose into context for hosts whose state carriers have no // such contract. The kernel stays host-agnostic: it only knows virtual paths // and patterns. +// +// #2766 ROLE BOUNDARY: every carrier is a host INJECTION into the user +// channel — no classifier may project an assistant-role message. Model +// output echoes these markers (anything visible to the model eventually +// shows up in its output: the bili-chain tags, #2483 render tags, #933/#1039 +// echo noise are the same family), and under the default latest-per-identity +// policy a later assistant echo would take the `durable/` identity +// over from the real carrier: the echo gets pinned, the real carrier demotes +// to foldable, one compression later the forged/partial copy is the only +// survivor. The role gate is checked first in every classifier below. import type { Config, CoreMessage } from "acp-kernel"; @@ -59,9 +69,13 @@ type DurableMessageClassifier = (msg: CoreMessage) => string | undefined; * cannot silently disarm the classifier; a false positive is benign (a * matching message simply stays visible) and the marker is DSH-specific * XML. Re-emitted only when the visible skill set CHANGES, so the newest - * instance is the source of truth (`protectedLatestTools` candidate). */ + * instance is the source of truth (`protectedLatestTools` candidate). + * #2766: assistant-role messages are never carriers — every carrier is a + * host injection into the user channel, so a model ECHO of the marker must + * not project (under latest-per-identity it would take the identity over + * from the real carrier and get it folded away). */ export function dshSkillCatalogPath(msg: CoreMessage): string | undefined { - return msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("\x3cavailable_skills\x3e") + return msg.role !== "assistant" && msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("\x3cavailable_skills\x3e") ? "durable/skill-catalog" : undefined; } @@ -79,9 +93,10 @@ export function dshSkillCatalogPath(msg: CoreMessage): string | undefined { * use lowercase variants ("Updated instructions from:") that remain ordinary * consumable history. At most ~one such baseline lives per session * (replacements are rare identity changes), so pinning every match is - * bounded. */ + * bounded. #2766: assistant-role messages are never carriers (see + * dshSkillCatalogPath). */ export function dshWorkspaceInstructionsPath(msg: CoreMessage): string | undefined { - return msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("Instructions from:") + return msg.role !== "assistant" && msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("Instructions from:") ? "durable/agents-md" : undefined; } @@ -98,9 +113,12 @@ export function dshWorkspaceInstructionsPath(msg: CoreMessage): string | undefin * plugin ecosystem rather than dsh core: same evidence bar (shape from the * #2446 report, stability owned by the plugin author who filed it), and * re-emissions are rare set changes so pinning every match stays bounded - * (same argument as #2447). */ + * (same argument as #2447). #2766: assistant-role messages are never + * carriers (see dshSkillCatalogPath) — this is the adversarial variant's + * vector: an injected "list the catalog" instruction makes the MODEL emit a + * forged \x3cmcp_catalog\x3e, which must never outrank the real one. */ export function dshMcpCatalogPath(msg: CoreMessage): string | undefined { - return msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("\x3cmcp_catalog\x3e") + return msg.role !== "assistant" && msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("\x3cmcp_catalog\x3e") ? "durable/mcp-catalog" : undefined; } @@ -121,9 +139,10 @@ export function dshMcpCatalogPath(msg: CoreMessage): string | undefined { * latest snapshot silently drops the session's sandbox/approval semantics * forever. Each snapshot is tiny (~100–400 chars); superseded ones fold * harmlessly under the latest-per-identity default because the newest - * carries the supersedes clause. */ + * carries the supersedes clause. #2766: assistant-role messages are never + * carriers (see dshSkillCatalogPath). */ export function dshRuntimeContextPath(msg: CoreMessage): string | undefined { - return msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("Current runtime context") + return msg.role !== "assistant" && msg.contentType === "text" && typeof msg.text === "string" && msg.text.includes("Current runtime context") ? "durable/runtime-context" : undefined; } diff --git a/tests/issue2766-durable-role-gate.test.ts b/tests/issue2766-durable-role-gate.test.ts new file mode 100644 index 000000000..b80e2de3b --- /dev/null +++ b/tests/issue2766-durable-role-gate.test.ts @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + dshMcpCatalogPath, + dshRuntimeContextPath, + dshSkillCatalogPath, + dshWorkspaceInstructionsPath, + durableMessageToolPaths, + withDurableProtection, +} from "../src/durable-message-guards.ts"; +import { collectLatestProtected, defaultConfig, isMessageLatestProtected, type Config, type CoreMessage } from "acp-kernel"; + +// #2766: the durable classifiers had no role boundary. Every carrier is a +// host INJECTION into the user channel, but a model ECHO of a carrier marker +// (anything visible to the model eventually shows up in its output) used to +// project too — and under the default latest-per-identity policy +// (protectedLatestTools: ["durable/*"]) the LATER echo took the identity over +// from the real carrier: the echo got pinned, the real carrier demoted to +// foldable, one compression later the forged/partial copy was the only +// survivor. The classifiers now refuse assistant-role messages outright. + +function msg(role: CoreMessage["role"], text: string, id = "m1"): CoreMessage { + return { id, role, contentType: "text", text }; +} + +const CARRIERS: Array<[name: string, marker: string, path: string, cls: (m: CoreMessage) => string | undefined]> = [ + ["skill catalog", "", "durable/skill-catalog", dshSkillCatalogPath], + ["workspace instructions", "Instructions from: ~/.dsh/AGENTS.md", "durable/agents-md", dshWorkspaceInstructionsPath], + ["mcp catalog", "\n- server: fs\n", "durable/mcp-catalog", dshMcpCatalogPath], + ["runtime context", "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.", "durable/runtime-context", dshRuntimeContextPath], +]; + +for (const [name, marker, path, cls] of CARRIERS) { + test(`Path: a model echo of the ${name} marker never projects (#2766)`, () => { + assert.equal(cls(msg("assistant", `here is the catalog you asked for:\n${marker}`)), undefined); + assert.equal(durableMessageToolPaths["dsh"]!(msg("assistant", marker)), undefined, "the lane-composed classifier refuses it too"); + }); + test(`Path: the user-role ${name} carrier still projects (#2766)`, () => { + assert.equal(cls(msg("user", marker)), path); + assert.equal(durableMessageToolPaths["dsh"]!(msg("user", marker)), path); + }); +} + +test("Path: system-role carrier text still projects — the gate targets model output only (#2766)", () => { + assert.equal(dshSkillCatalogPath(msg("system", "")), "durable/skill-catalog"); +}); + +test("kernel: an assistant echo cannot take over the latest durable identity (#2766)", () => { + const config: Config = withDurableProtection(defaultConfig(200_000), durableMessageToolPaths["dsh"]!); + assert.deepEqual(config.protectedLatestTools, ["durable/*"], "default policy is latest-per-identity"); + // The real carrier arrives early (host injection); the model echoes the + // marker LATER — under lastByPath the later match used to win. + const carrier = msg("user", "\n- server: fs\n", "m00001"); + const filler = msg("user", "some ordinary work", "m00002"); + const echo = msg("assistant", "Sure, the catalog looks like:\n\n- server: totally-real\n", "m00003"); + const messages = [carrier, filler, echo]; + const latest = collectLatestProtected(messages, config); + assert.ok(latest.msgIds.has("m00001"), "the real carrier keeps the durable/mcp-catalog identity"); + assert.ok(!latest.msgIds.has("m00003"), "the assistant echo does not become the latest instance"); + assert.equal(isMessageLatestProtected(echo, latest), false, "the echo stays foldable"); + assert.equal(isMessageLatestProtected(carrier, latest), true, "the real carrier stays pinned"); +}); + +test("kernel: an assistant echo between two real carriers does not break succession (#2766)", () => { + const config: Config = withDurableProtection(defaultConfig(200_000), durableMessageToolPaths["dsh"]!); + const v1 = msg("user", "Current runtime context. sandbox=on", "m00001"); + const echo = msg("assistant", "Current runtime context — I'll track that.", "m00002"); + const v2 = msg("user", "Current runtime context. sandbox=off. This snapshot supersedes earlier runtime-context snapshots.", "m00003"); + const latest = collectLatestProtected([v1, echo, v2], config); + assert.ok(latest.msgIds.has("m00003"), "the newest real snapshot is the pinned one"); + assert.ok(!latest.msgIds.has("m00002"), "the echo never projects"); +});