openclaw/src/gateway/message-approval-manager.ts
Glucksberg bc7ba73a34 feat(gateway): add human-in-the-loop approval for outbound messaging (#2023)
Add `approvals.message` config that requires human confirmation before
executing outbound messaging tools. Follows the existing exec approval
pattern with in-memory state management and Promise-based waiting.

New components:
- MessageApprovalManager: tracks pending approvals with timeout handling
- Gateway RPC handlers: message.approval.request and message.approval.resolve
- MessageApprovalForwarder: delivers approval requests to chat channels
- Approval interception in runMessageAction with gatewayClient/skipApproval params

Extended /approve command to handle message approvals (IDs starting with "msg-").

Config schema added under approvals.message with options for:
- enabled, mode (session/targets/both), actions, channels
- agentFilter, sessionFilter, targets, timeout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:49:25 +00:00

85 lines
2.2 KiB
TypeScript

import { randomUUID } from "node:crypto";
export type MessageApprovalDecision = "allow" | "deny";
export type MessageApprovalRequestPayload = {
action: string;
channel: string;
to: string;
message?: string | null;
mediaUrl?: string | null;
agentId?: string | null;
sessionKey?: string | null;
};
export type MessageApprovalRecord = {
id: string;
request: MessageApprovalRequestPayload;
createdAtMs: number;
expiresAtMs: number;
resolvedAtMs?: number;
decision?: MessageApprovalDecision;
resolvedBy?: string | null;
};
type PendingEntry = {
record: MessageApprovalRecord;
resolve: (decision: MessageApprovalDecision | null) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export class MessageApprovalManager {
private pending = new Map<string, PendingEntry>();
create(
request: MessageApprovalRequestPayload,
timeoutMs: number,
id?: string | null,
): MessageApprovalRecord {
const now = Date.now();
const resolvedId = id && id.trim().length > 0 ? id.trim() : `msg-${randomUUID()}`;
const record: MessageApprovalRecord = {
id: resolvedId,
request,
createdAtMs: now,
expiresAtMs: now + timeoutMs,
};
return record;
}
async waitForDecision(
record: MessageApprovalRecord,
timeoutMs: number,
): Promise<MessageApprovalDecision | null> {
return await new Promise<MessageApprovalDecision | null>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(record.id);
resolve(null);
}, timeoutMs);
this.pending.set(record.id, { record, resolve, reject, timer });
});
}
resolve(
recordId: string,
decision: MessageApprovalDecision,
resolvedBy?: string | null,
): boolean {
const pending = this.pending.get(recordId);
if (!pending) return false;
clearTimeout(pending.timer);
pending.record.resolvedAtMs = Date.now();
pending.record.decision = decision;
pending.record.resolvedBy = resolvedBy ?? null;
this.pending.delete(recordId);
pending.resolve(decision);
return true;
}
getSnapshot(recordId: string): MessageApprovalRecord | null {
const entry = this.pending.get(recordId);
return entry?.record ?? null;
}
}