fix(imessage): surface Full Disk Access probe failures

Preserve canonical iMessage Full Disk Access probe failures through non-sensitive health snapshots and status output, promote imsg denial banners to the public remediation message, and add a narrow audit exception for the reviewed Mistral advisory false-positive.
This commit is contained in:
Andy Ye
2026-05-11 21:41:08 -07:00
committed by GitHub
parent a6892a5af3
commit 7624b0d16d
8 changed files with 330 additions and 12 deletions

View File

@@ -231,6 +231,7 @@ Docs: https://docs.openclaw.ai
- Gateway/logging: install console capture before foreground Gateway fast-path parsing and suppress known libsignal session dumps even in verbose mode, preventing raw terminal logs from printing WhatsApp session key material. (#76306) Thanks @rubencu.
- Exec approvals: keep `exec.approval.list` on the lightweight policy-summary path so listing pending approvals no longer loads the rich tree-sitter command explainer. (#76943) Thanks @rubencu.
- Agents: surface concise default-visible warnings when `exec`/`bash` tool calls fail after the assistant claims success, while keeping raw stderr hidden unless verbose details are enabled. Fixes #60497. (#80003) Thanks @jbetala7.
- Channels/iMessage: keep redacted failed probe details in non-sensitive health snapshots so Full Disk Access failures no longer appear as configured/OK in status output. Fixes #79795.
- Agents: stop blank model-emitted tool calls before dispatch while preserving id-based tool-name recovery, preventing Kimi/NVIDIA blank-name retry loops without creating a callable `_blank` sentinel. Fixes #34129. (#56391) Thanks @smartchainark.
- Agents/Telegram: deliver the canonical final assistant answer instead of replaying accumulated pre-tool text blocks, preventing duplicate Telegram replies and raw-looking tool-output fragments from leaking into chat delivery. Fixes #79621 and #79986. Thanks @nonzeroclaw and @dudaefj.
- Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle.

View File

@@ -39,6 +39,9 @@ type PendingRequest = {
timer?: NodeJS.Timeout;
};
export const PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR =
"imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.";
function isTestEnv(): boolean {
if (process.env.NODE_ENV === "test") {
return true;
@@ -47,6 +50,14 @@ function isTestEnv(): boolean {
return Boolean(vitest);
}
export function normalizeIMessageFullDiskAccessError(message: string): string | undefined {
const normalized = normalizeLowercaseStringOrEmpty(message);
if (!normalized.includes("full disk access") || !normalized.includes("chat.db")) {
return undefined;
}
return PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR;
}
export class IMessageRpcClient {
private readonly cliPath: string;
private readonly dbPath?: string;
@@ -58,6 +69,7 @@ export class IMessageRpcClient {
private child: ChildProcessWithoutNullStreams | null = null;
private reader: Interface | null = null;
private nextId = 1;
private publicProcessError: string | null = null;
constructor(opts: IMessageRpcClientOptions = {}) {
this.cliPath = opts.cliPath?.trim() || "imsg";
@@ -100,7 +112,9 @@ export class IMessageRpcClient {
if (!line.trim()) {
continue;
}
this.runtime?.error?.(`imsg rpc: ${line.trim()}`);
const trimmed = line.trim();
this.recordProcessDiagnostic(trimmed);
this.runtime?.error?.(`imsg rpc: ${trimmed}`);
}
});
@@ -116,12 +130,7 @@ export class IMessageRpcClient {
});
child.on("close", (code, signal) => {
if (code !== 0 && code !== null) {
const reason = signal ? `signal ${signal}` : `code ${code}`;
this.failAll(new Error(`imsg rpc exited (${reason})`));
} else {
this.failAll(new Error("imsg rpc closed"));
}
this.failAll(this.buildCloseError(code, signal));
this.closedResolve?.();
});
}
@@ -210,6 +219,7 @@ export class IMessageRpcClient {
try {
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
} catch (err) {
this.recordProcessDiagnostic(line);
const detail = formatErrorMessage(err);
this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`);
return;
@@ -257,6 +267,21 @@ export class IMessageRpcClient {
}
}
private recordProcessDiagnostic(line: string): void {
this.publicProcessError ??= normalizeIMessageFullDiskAccessError(line) ?? null;
}
private buildCloseError(code: number | null, signal: NodeJS.Signals | null): Error {
if (this.publicProcessError) {
return new Error(this.publicProcessError);
}
if (code !== 0 && code !== null) {
const reason = signal ? `signal ${signal}` : `code ${code}`;
return new Error(`imsg rpc exited (${reason})`);
}
return new Error("imsg rpc closed");
}
private failAll(err: Error) {
for (const [key, pending] of this.pending.entries()) {
if (pending.timer) {

View File

@@ -50,6 +50,22 @@ describe("createIMessageRpcClient", () => {
);
expect(spawnMock).not.toHaveBeenCalled();
});
it("promotes Full Disk Access rpc banners to the public probe error", async () => {
const { IMessageRpcClient, PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR } =
await import("./client.js");
const client = new IMessageRpcClient();
const internals = client as unknown as {
handleLine: (line: string) => void;
buildCloseError: (code: number | null, signal: NodeJS.Signals | null) => Error;
};
internals.handleLine(
"imsg cannot access /Users/alice/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.",
);
expect(internals.buildCloseError(1, null).message).toBe(PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR);
});
});
describe("imessage setup status", () => {

View File

@@ -23,6 +23,16 @@ const NESTED_MAPPING_ENTRY_INDENT = 8;
const SNAPSHOT_SECTIONS = ["dependencies", "optionalDependencies"];
const IMPORTER_SECTIONS = ["dependencies", "optionalDependencies"];
const LOCAL_REFERENCE_PREFIXES = ["file:", "link:", "portal:", "workspace:"];
// GitHub's GHSA-3q49-cfcf-g5fm feed includes an overbroad ">=0" range alongside
// the compromised @mistralai/mistralai versions. Keep the production audit
// blocking for the compromised releases while allowing our pinned 2.2.1 lock.
const AUDIT_ADVISORY_VERSION_OVERRIDES = [
{
packageName: "@mistralai/mistralai",
advisoryIds: new Set(["1118204", "GHSA-3q49-cfcf-g5fm"]),
unaffectedVersions: new Set(["2.2.1"]),
},
];
export function normalizeAuditLevel(level) {
const normalized = String(level ?? "").toLowerCase();
@@ -560,7 +570,34 @@ function normalizeSeverity(severity) {
return severity.toLowerCase();
}
export function filterFindingsBySeverity(advisoriesByPackage, minSeverity) {
function advisoryMatchesOverride(advisory, override) {
const advisoryId = String(advisory?.id ?? "");
const advisoryUrl = typeof advisory?.url === "string" ? advisory.url : "";
return (
override.advisoryIds.has(advisoryId) ||
[...override.advisoryIds].some((id) => advisoryUrl.includes(id))
);
}
function shouldSuppressAdvisoryFinding({ packageName, advisory, versionsByPackage }) {
if (!versionsByPackage) {
return false;
}
const override = AUDIT_ADVISORY_VERSION_OVERRIDES.find(
(candidate) =>
candidate.packageName === packageName && advisoryMatchesOverride(advisory, candidate),
);
if (!override) {
return false;
}
const resolvedVersions = versionsByPackage.get(packageName);
if (!resolvedVersions || resolvedVersions.size === 0) {
return false;
}
return [...resolvedVersions].every((version) => override.unaffectedVersions.has(version));
}
export function filterFindingsBySeverity(advisoriesByPackage, minSeverity, versionsByPackage) {
const threshold = normalizeAuditLevel(minSeverity);
const findings = [];
@@ -576,6 +613,9 @@ export function filterFindingsBySeverity(advisoriesByPackage, minSeverity) {
if ((SEVERITY_RANK[severity] ?? -1) < SEVERITY_RANK[threshold]) {
continue;
}
if (shouldSuppressAdvisoryFinding({ packageName, advisory, versionsByPackage })) {
continue;
}
findings.push({
packageName,
id: advisory.id ?? "unknown",
@@ -651,7 +691,8 @@ export async function runPnpmAuditProd({
const normalizedMinSeverity = normalizeAuditLevel(minSeverity);
const lockfilePath = path.join(rootDir, "pnpm-lock.yaml");
const lockfileText = await readFile(lockfilePath, "utf8");
const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfileText));
const versionsByPackage = collectProdResolvedPackagesFromLockfile(lockfileText);
const payload = createBulkAdvisoryPayload(versionsByPackage);
const payloadEntries = Object.entries(payload);
if (payloadEntries.length === 0) {
@@ -669,7 +710,11 @@ export async function runPnpmAuditProd({
Object.assign(advisoryResults, chunkResults);
}
const findings = filterFindingsBySeverity(advisoryResults, normalizedMinSeverity);
const findings = filterFindingsBySeverity(
advisoryResults,
normalizedMinSeverity,
versionsByPackage,
);
if (findings.length === 0) {
stdout.write(
`No ${normalizedMinSeverity} or higher advisories found for production dependencies.\n`,

View File

@@ -42,6 +42,12 @@ type DiscordHealthAccount = {
configured: boolean;
};
type IMessageHealthAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
};
async function loadFreshHealthModulesForTest() {
vi.doMock("../config/config.js", () => ({
getRuntimeConfig: () => testConfig,
@@ -405,6 +411,43 @@ function createDiscordHealthPlugin(): HealthTestPlugin {
};
}
function createIMessageHealthPlugin(): HealthTestPlugin {
return {
...createChannelTestPluginBase({ id: "imessage", label: "iMessage" }),
config: {
listAccountIds: () => ["default"],
resolveAccount: (_cfg, accountId) => ({
accountId: accountId?.trim() || "default",
enabled: true,
configured: true,
}),
inspectAccount: (_cfg, accountId) => ({
accountId: accountId?.trim() || "default",
enabled: true,
configured: true,
}),
isEnabled: (account) => (account as IMessageHealthAccount).enabled,
isConfigured: (account) => (account as IMessageHealthAccount).configured,
},
status: {
buildChannelSummary: ({ snapshot }) => ({
accountId: snapshot.accountId,
configured: Boolean(snapshot.configured),
...(snapshot.probe && typeof snapshot.probe === "object" ? { probe: snapshot.probe } : {}),
}),
probeAccount: async () => ({
ok: false,
error:
"imsg cannot access /Users/alice/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway. privateApi=/tmp/openclaw/private.sock",
privateApi: {
rpcCommand: "imsg rpc --json",
diagnostics: "sensitive transport details",
},
}),
},
};
}
describe("getHealthSnapshot", () => {
beforeAll(async () => {
({
@@ -744,6 +787,75 @@ describe("getHealthSnapshot", () => {
expect(telegram.accounts?.default?.channelAccessToken).toBeUndefined();
});
it("keeps redacted failed probes in non-sensitive health snapshots", async () => {
healthPluginsForTest = [createIMessageHealthPlugin()];
testConfig = { channels: { imessage: { enabled: true } } };
testStore = {};
const snap = await getHealthSnapshot({
timeoutMs: 25,
includeSensitive: false,
});
const imessage = snap.channels.imessage as {
configured?: boolean;
probe?: {
ok?: boolean;
error?: string;
privateApi?: unknown;
};
accounts?: Record<
string,
{
probe?: {
ok?: boolean;
error?: string;
privateApi?: unknown;
};
}
>;
};
expect(imessage.configured).toBe(true);
expect(imessage.probe).toMatchObject({
ok: false,
error:
"imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.",
});
expect(imessage.probe?.privateApi).toBeUndefined();
expect(imessage.accounts?.default?.probe).toMatchObject({
ok: false,
error:
"imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.",
});
expect(imessage.accounts?.default?.probe?.privateApi).toBeUndefined();
});
it("omits generic failed probe errors from non-sensitive health snapshots", async () => {
testConfig = { channels: { telegram: { botToken: "bad-token" } } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network down with private diagnostic");
}),
);
const snap = await getHealthSnapshot({
timeoutMs: 25,
includeSensitive: false,
});
const telegram = snap.channels.telegram as {
configured?: boolean;
probe?: unknown;
accounts?: Record<string, { probe?: unknown }>;
};
expect(telegram.configured).toBe(true);
expect(telegram.probe).toBeUndefined();
expect(telegram.accounts?.default?.probe).toBeUndefined();
});
it("returns structured telegram probe errors", async () => {
testConfig = { channels: { telegram: { botToken: "bad-token" } } };
testStore = {};

View File

@@ -227,6 +227,29 @@ describe("healthCommand", () => {
const lines = formatHealthChannelLines(summary, { accountMode: "default" });
expect(lines).toStrictEqual(["WhatsApp: auth stabilizing"]);
});
it("formats iMessage probe failures as failed health lines", () => {
const summary = createHealthSummary({
channels: {
imessage: {
accountId: "default",
configured: true,
probe: {
ok: false,
error:
"imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.",
},
},
},
channelOrder: ["imessage"],
channelLabels: { imessage: "iMessage" },
});
const lines = formatHealthChannelLines(summary, { accountMode: "default" });
expect(lines).toContain(
"iMessage: failed (unknown) - imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.",
);
});
});
describe("formatHealthCheckFailure", () => {

View File

@@ -70,6 +70,43 @@ const debugHealth = (...args: unknown[]) => {
}
};
const PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR =
"imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.";
const redactIMessageProbeErrorMessage = (message: string): string => {
const trimmed = message.trim();
if (!trimmed) {
return "";
}
return trimmed.replaceAll(
/\/Users\/[^/\s]+\/Library\/Messages\/chat\.db/g,
"~/Library/Messages/chat.db",
);
};
const buildNonSensitiveProbeFailure = (
channelId: string,
probe: unknown,
): Record<string, unknown> | undefined => {
const record = asNullableRecord(probe);
if (channelId !== "imessage" || !record || record.ok !== false) {
return undefined;
}
if (typeof record.error !== "string") {
return undefined;
}
const error = redactIMessageProbeErrorMessage(record.error);
if (
!/\bimsg\b/i.test(error) ||
!error.includes("~/Library/Messages/chat.db") ||
!/\bFull Disk Access\b/i.test(error)
) {
return undefined;
}
return { ok: false, error: PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR };
};
const formatDurationParts = (ms: number): string => {
if (!Number.isFinite(ms)) {
return "unknown";
@@ -453,13 +490,15 @@ export async function getHealthSnapshot(params?: {
const runtimeSnapshot =
params?.runtimeSnapshot?.channelAccounts[plugin.id]?.[accountId] ??
(accountId === defaultAccountId ? params?.runtimeSnapshot?.channels[plugin.id] : undefined);
const nonSensitiveProbeFailure = buildNonSensitiveProbeFailure(plugin.id, probe);
const snapshotProbe = includeSensitive ? probe : nonSensitiveProbeFailure;
const snapshot: ChannelAccountSnapshot = await buildChannelAccountSnapshotFromAccount({
plugin,
cfg,
accountId,
account: snapshotAccount,
runtime: runtimeSnapshot,
probe: includeSensitive ? probe : undefined,
probe: snapshotProbe,
enabledFallback: enabled,
configuredFallback: configured,
});
@@ -499,7 +538,13 @@ export async function getHealthSnapshot(params?: {
record.probe = probe;
}
if (!includeSensitive) {
delete record.probe;
const summaryProbeFailure = buildNonSensitiveProbeFailure(plugin.id, record.probe);
const safeProbeFailure = summaryProbeFailure ?? nonSensitiveProbeFailure;
if (safeProbeFailure) {
record.probe = safeProbeFailure;
} else {
delete record.probe;
}
}
if (record.lastProbeAt === undefined && lastProbeAt) {
record.lastProbeAt = lastProbeAt;

View File

@@ -166,6 +166,57 @@ snapshots:
]);
});
it("suppresses the overbroad Mistral malware advisory for the pre-compromise locked version", () => {
const versionsByPackage = new Map([["@mistralai/mistralai", new Set(["2.2.1"])]]);
const findings = filterFindingsBySeverity(
{
"@mistralai/mistralai": [
{
id: "1118204",
severity: "critical",
title: "Malware in @mistralai/mistralai",
vulnerable_versions: ">=0",
url: "https://github.com/advisories/GHSA-3q49-cfcf-g5fm",
},
],
},
"high",
versionsByPackage,
);
expect(findings).toEqual([]);
});
it("keeps the Mistral malware advisory blocking for compromised resolved versions", () => {
const versionsByPackage = new Map([["@mistralai/mistralai", new Set(["2.2.4"])]]);
const findings = filterFindingsBySeverity(
{
"@mistralai/mistralai": [
{
id: "1118204",
severity: "critical",
title: "Malware in @mistralai/mistralai",
vulnerable_versions: ">=0",
url: "https://github.com/advisories/GHSA-3q49-cfcf-g5fm",
},
],
},
"high",
versionsByPackage,
);
expect(findings).toEqual([
{
id: "1118204",
packageName: "@mistralai/mistralai",
severity: "critical",
title: "Malware in @mistralai/mistralai",
url: "https://github.com/advisories/GHSA-3q49-cfcf-g5fm",
vulnerableVersions: ">=0",
},
]);
});
it("returns a failing exit code when bulk advisories include high severity findings", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "openclaw-audit-prod-"));
await writeFile(