Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
35 changes: 27 additions & 8 deletions src/durable-message-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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";

Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
72 changes: 72 additions & 0 deletions tests/issue2766-durable-role-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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", "<available_skills>", "durable/skill-catalog", dshSkillCatalogPath],
["workspace instructions", "Instructions from: ~/.dsh/AGENTS.md", "durable/agents-md", dshWorkspaceInstructionsPath],
["mcp catalog", "<mcp_catalog>\n- server: fs\n</mcp_catalog>", "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", "<available_skills>")), "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", "<mcp_catalog>\n- server: fs\n</mcp_catalog>", "m00001");
const filler = msg("user", "some ordinary work", "m00002");
const echo = msg("assistant", "Sure, the catalog looks like:\n<mcp_catalog>\n- server: totally-real\n</mcp_catalog>", "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");
});
Loading