fix(security): remove post-compaction audit injection message (#28507)
* fix: remove post-compaction audit injection (Layer 3)
Remove the post-compaction read audit that injects fake system messages
into conversations after context compaction. This audit:
- Hardcodes WORKFLOW_AUTO.md (a file that doesn't exist in standard
workspaces) as a required read after every compaction
- Leaks raw regex syntax (memory\/\d{4}-\d{2}-\d{2}\.md) in
user-facing warning messages
- Injects messages via enqueueSystemEvent that appear as user-role
messages, tricking agents into reading attacker-controlled files
- Creates a persistent prompt injection vector (see #27697)
Layer 1 (compaction summary) and Layer 2 (workspace context refresh
from AGENTS.md via post-compaction-context.ts) remain intact and are
sufficient for post-compaction context recovery.
Deleted files:
- src/auto-reply/reply/post-compaction-audit.ts
- src/auto-reply/reply/post-compaction-audit.test.ts
Modified files:
- src/auto-reply/reply/agent-runner.ts (removed imports, audit map,
flag setting, and Layer 3 audit block)
Fixes #27697, fixes #26851, fixes #20484, fixes #22339, fixes #25600
Relates to #26461
* fix: resolve lint failures from post-compaction audit removal
* Tests: add regression for removed post-compaction audit warnings
---------
Co-authored-by: Wilfred (OpenClaw Agent) <jay@openclaw.dev>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { loadSessionStore, saveSessionStore } from "../../config/sessions.js";
|
||||
import { onAgentEvent } from "../../infra/agent-events.js";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
@@ -79,6 +80,7 @@ beforeEach(() => {
|
||||
runCliAgentMock.mockClear();
|
||||
runWithModelFallbackMock.mockClear();
|
||||
runtimeErrorMock.mockClear();
|
||||
resetSystemEventsForTest();
|
||||
|
||||
// Default: no provider switch; execute the chosen provider+model.
|
||||
runWithModelFallbackMock.mockImplementation(
|
||||
@@ -92,6 +94,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetSystemEventsForTest();
|
||||
});
|
||||
|
||||
describe("runReplyAgent onAgentRunStart", () => {
|
||||
@@ -328,6 +331,8 @@ describe("runReplyAgent auto-compaction token update", () => {
|
||||
storePath: string;
|
||||
sessionEntry: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
sessionFile?: string;
|
||||
workspaceDir?: string;
|
||||
}) {
|
||||
const typing = createMockTypingController();
|
||||
const sessionCtx = {
|
||||
@@ -347,8 +352,8 @@ describe("runReplyAgent auto-compaction token update", () => {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
sessionFile: params.sessionFile ?? "/tmp/session.jsonl",
|
||||
workspaceDir: params.workspaceDir ?? "/tmp",
|
||||
config: params.config ?? {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
@@ -495,6 +500,84 @@ describe("runReplyAgent auto-compaction token update", () => {
|
||||
// totalTokens should use lastCallUsage (55k), not accumulated (75k)
|
||||
expect(stored[sessionKey].totalTokens).toBe(55_000);
|
||||
});
|
||||
|
||||
it("does not enqueue legacy post-compaction audit warnings", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-no-audit-warning-"));
|
||||
const workspaceDir = path.join(tmp, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
const sessionFile = path.join(tmp, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
`${JSON.stringify({ type: "message", message: { role: "assistant", content: [] } })}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const storePath = path.join(tmp, "sessions.json");
|
||||
const sessionKey = "main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 10_000,
|
||||
compactionCount: 0,
|
||||
};
|
||||
|
||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||
params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } });
|
||||
params.onAgentEvent?.({ stream: "compaction", data: { phase: "end", willRetry: false } });
|
||||
return {
|
||||
payloads: [{ text: "done" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
usage: { input: 11_000, output: 500, total: 11_500 },
|
||||
lastCallUsage: { input: 10_500, output: 500, total: 11_000 },
|
||||
compactionCount: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const config = {
|
||||
agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } },
|
||||
};
|
||||
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
|
||||
storePath,
|
||||
sessionEntry,
|
||||
config,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
await runReplyAgent({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
typing,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore: { [sessionKey]: sessionEntry },
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
agentCfgContextTokens: 200_000,
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: "instant",
|
||||
});
|
||||
|
||||
const queuedSystemEvents = peekSystemEvents(sessionKey);
|
||||
expect(queuedSystemEvents.some((event) => event.includes("Post-Compaction Audit"))).toBe(false);
|
||||
expect(queuedSystemEvents.some((event) => event.includes("WORKFLOW_AUTO.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runReplyAgent block streaming", () => {
|
||||
|
||||
@@ -44,12 +44,6 @@ import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-repl
|
||||
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js";
|
||||
import {
|
||||
auditPostCompactionReads,
|
||||
extractReadPaths,
|
||||
formatAuditWarning,
|
||||
readSessionMessages,
|
||||
} from "./post-compaction-audit.js";
|
||||
import { readPostCompactionContext } from "./post-compaction-context.js";
|
||||
import { resolveActiveRunQueueAction } from "./queue-policy.js";
|
||||
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
|
||||
@@ -95,9 +89,6 @@ function appendUnscheduledReminderNote(payloads: ReplyPayload[]): ReplyPayload[]
|
||||
});
|
||||
}
|
||||
|
||||
// Track sessions pending post-compaction read audit (Layer 3)
|
||||
const pendingPostCompactionAudits = new Map<string, boolean>();
|
||||
|
||||
export async function runReplyAgent(params: {
|
||||
commandBody: string;
|
||||
followupRun: FollowupRun;
|
||||
@@ -704,9 +695,6 @@ export async function runReplyAgent(params: {
|
||||
.catch(() => {
|
||||
// Silent failure — post-compaction context is best-effort
|
||||
});
|
||||
|
||||
// Set pending audit flag for Layer 3 (post-compaction read audit)
|
||||
pendingPostCompactionAudits.set(sessionKey, true);
|
||||
}
|
||||
|
||||
if (verboseEnabled) {
|
||||
@@ -721,25 +709,6 @@ export async function runReplyAgent(params: {
|
||||
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
||||
}
|
||||
|
||||
// Post-compaction read audit (Layer 3)
|
||||
if (sessionKey && pendingPostCompactionAudits.get(sessionKey)) {
|
||||
pendingPostCompactionAudits.delete(sessionKey); // Delete FIRST — one-shot only
|
||||
try {
|
||||
const sessionFile = activeSessionEntry?.sessionFile;
|
||||
if (sessionFile) {
|
||||
const messages = readSessionMessages(sessionFile);
|
||||
const readPaths = extractReadPaths(messages);
|
||||
const workspaceDir = process.cwd();
|
||||
const audit = auditPostCompactionReads(readPaths, workspaceDir);
|
||||
if (!audit.passed) {
|
||||
enqueueSystemEvent(formatAuditWarning(audit.missingPatterns), { sessionKey });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent failure — audit is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeWithFollowup(
|
||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||
queueKey,
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
auditPostCompactionReads,
|
||||
extractReadPaths,
|
||||
formatAuditWarning,
|
||||
} from "./post-compaction-audit.js";
|
||||
|
||||
describe("extractReadPaths", () => {
|
||||
it("extracts file paths from Read tool calls", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "read",
|
||||
input: { file_path: "WORKFLOW_AUTO.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "read",
|
||||
input: { file_path: "memory/2026-02-16.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const paths = extractReadPaths(messages);
|
||||
expect(paths).toEqual(["WORKFLOW_AUTO.md", "memory/2026-02-16.md"]);
|
||||
});
|
||||
|
||||
it("handles path parameter (alternative to file_path)", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "read",
|
||||
input: { path: "AGENTS.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const paths = extractReadPaths(messages);
|
||||
expect(paths).toEqual(["AGENTS.md"]);
|
||||
});
|
||||
|
||||
it("ignores non-assistant messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "read",
|
||||
input: { file_path: "should_be_ignored.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const paths = extractReadPaths(messages);
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores non-read tool calls", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "exec",
|
||||
input: { command: "cat WORKFLOW_AUTO.md" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const paths = extractReadPaths(messages);
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty messages array", () => {
|
||||
const paths = extractReadPaths([]);
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles messages with non-array content", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "text only",
|
||||
},
|
||||
];
|
||||
|
||||
const paths = extractReadPaths(messages);
|
||||
expect(paths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auditPostCompactionReads", () => {
|
||||
const workspaceDir = "/Users/test/workspace";
|
||||
|
||||
it("passes when all required files are read", () => {
|
||||
const readPaths = ["WORKFLOW_AUTO.md", "memory/2026-02-16.md"];
|
||||
const result = auditPostCompactionReads(readPaths, workspaceDir);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.missingPatterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails when no files are read", () => {
|
||||
const result = auditPostCompactionReads([], workspaceDir);
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.missingPatterns).toContain("WORKFLOW_AUTO.md");
|
||||
expect(result.missingPatterns.some((p) => p.includes("memory"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports only missing files", () => {
|
||||
const readPaths = ["WORKFLOW_AUTO.md"];
|
||||
const result = auditPostCompactionReads(readPaths, workspaceDir);
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.missingPatterns).not.toContain("WORKFLOW_AUTO.md");
|
||||
expect(result.missingPatterns.some((p) => p.includes("memory"))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches RegExp patterns against relative paths", () => {
|
||||
const readPaths = ["memory/2026-02-16.md"];
|
||||
const result = auditPostCompactionReads(readPaths, workspaceDir);
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.missingPatterns).toContain("WORKFLOW_AUTO.md");
|
||||
expect(result.missingPatterns.length).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes relative paths when matching", () => {
|
||||
const readPaths = ["./WORKFLOW_AUTO.md", "memory/2026-02-16.md"];
|
||||
const result = auditPostCompactionReads(readPaths, workspaceDir);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.missingPatterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes absolute paths when matching", () => {
|
||||
const readPaths = [
|
||||
"/Users/test/workspace/WORKFLOW_AUTO.md",
|
||||
"/Users/test/workspace/memory/2026-02-16.md",
|
||||
];
|
||||
const result = auditPostCompactionReads(readPaths, workspaceDir);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.missingPatterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts custom required reads list", () => {
|
||||
const readPaths = ["custom.md"];
|
||||
const customRequired = ["custom.md"];
|
||||
const result = auditPostCompactionReads(readPaths, workspaceDir, customRequired);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.missingPatterns).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAuditWarning", () => {
|
||||
it("formats warning message with missing patterns", () => {
|
||||
const missingPatterns = ["WORKFLOW_AUTO.md", "memory\\/\\d{4}-\\d{2}-\\d{2}\\.md"];
|
||||
const message = formatAuditWarning(missingPatterns);
|
||||
|
||||
expect(message).toContain("⚠️ Post-Compaction Audit");
|
||||
expect(message).toContain("WORKFLOW_AUTO.md");
|
||||
expect(message).toContain("memory");
|
||||
expect(message).toContain("Please read them now");
|
||||
});
|
||||
|
||||
it("formats single missing pattern", () => {
|
||||
const missingPatterns = ["WORKFLOW_AUTO.md"];
|
||||
const message = formatAuditWarning(missingPatterns);
|
||||
|
||||
expect(message).toContain("WORKFLOW_AUTO.md");
|
||||
// Check that the missing patterns list only contains WORKFLOW_AUTO.md
|
||||
const lines = message.split("\n");
|
||||
const patternLines = lines.filter((l) => l.trim().startsWith("- "));
|
||||
expect(patternLines).toHaveLength(1);
|
||||
expect(patternLines[0]).toContain("WORKFLOW_AUTO.md");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Default required files — constants, extensible to config later
|
||||
const DEFAULT_REQUIRED_READS: Array<string | RegExp> = [
|
||||
"WORKFLOW_AUTO.md",
|
||||
/memory\/\d{4}-\d{2}-\d{2}\.md/, // daily memory files
|
||||
];
|
||||
|
||||
/**
|
||||
* Audit whether agent read required startup files after compaction.
|
||||
* Returns list of missing file patterns.
|
||||
*/
|
||||
export function auditPostCompactionReads(
|
||||
readFilePaths: string[],
|
||||
workspaceDir: string,
|
||||
requiredReads: Array<string | RegExp> = DEFAULT_REQUIRED_READS,
|
||||
): { passed: boolean; missingPatterns: string[] } {
|
||||
const normalizedReads = readFilePaths.map((p) => path.resolve(workspaceDir, p));
|
||||
const missingPatterns: string[] = [];
|
||||
|
||||
for (const required of requiredReads) {
|
||||
if (typeof required === "string") {
|
||||
const requiredResolved = path.resolve(workspaceDir, required);
|
||||
const found = normalizedReads.some((r) => r === requiredResolved);
|
||||
if (!found) {
|
||||
missingPatterns.push(required);
|
||||
}
|
||||
} else {
|
||||
// RegExp — match against relative paths from workspace
|
||||
const found = readFilePaths.some((p) => {
|
||||
const rel = path.relative(workspaceDir, path.resolve(workspaceDir, p));
|
||||
// Normalize to forward slashes for cross-platform RegExp matching
|
||||
const normalizedRel = rel.split(path.sep).join("/");
|
||||
return required.test(normalizedRel);
|
||||
});
|
||||
if (!found) {
|
||||
missingPatterns.push(required.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: missingPatterns.length === 0, missingPatterns };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read messages from a session JSONL file.
|
||||
* Returns messages from the last N lines (default 100).
|
||||
*/
|
||||
export function readSessionMessages(
|
||||
sessionFile: string,
|
||||
maxLines = 100,
|
||||
): Array<{ role?: string; content?: unknown }> {
|
||||
if (!fs.existsSync(sessionFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(sessionFile, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
const recentLines = lines.slice(-maxLines);
|
||||
|
||||
const messages: Array<{ role?: string; content?: unknown }> = [];
|
||||
for (const line of recentLines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "message" && entry.message) {
|
||||
messages.push(entry.message);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file paths from Read tool calls in agent messages.
|
||||
* Looks for tool_use blocks with name="read" and extracts path/file_path args.
|
||||
*/
|
||||
export function extractReadPaths(messages: Array<{ role?: string; content?: unknown }>): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||
continue;
|
||||
}
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.name === "read") {
|
||||
const filePath = block.input?.file_path ?? block.input?.path;
|
||||
if (typeof filePath === "string") {
|
||||
paths.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Format the audit warning message */
|
||||
export function formatAuditWarning(missingPatterns: string[]): string {
|
||||
const fileList = missingPatterns.map((p) => ` - ${p}`).join("\n");
|
||||
return (
|
||||
"⚠️ Post-Compaction Audit: The following required startup files were not read after context reset:\n" +
|
||||
fileList +
|
||||
"\n\nPlease read them now using the Read tool before continuing. " +
|
||||
"This ensures your operating protocols are restored after memory compaction."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user