evOo-local/packages/core/src/ai-consent.ts
Claude 533f82d856
feat(media): disclose media servers in AI data sharing
Media tools and card actions send prompts, images, video and audio to the
deployment owner's ComfyUI and transcription servers. Like the embeddings
server behind built-in memory, those self-hosted endpoints are recipients in
the AI data-sharing disclosure: they appear with the model uses ("ComfyUI
(<origin>)", "Transcription (<origin>)", keyed by the full endpoint so a new
server asks again), and starting a card job asks for model consent on clients
that run the foreground check.

Also adds unit tests for the media settings store: URL normalization and
refusals, encryption, the key-free view, one decryption per saved version, key
retention per server and removing empty settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:51:13 +00:00

66 lines
2.3 KiB
TypeScript

import type { AiConsentStatus, AiDataUse, AiRecipient } from "@rakazo/contracts";
import { AI_CONSENT_REQUIRED } from "@rakazo/contracts";
/** A foreground check failed before the requested mutation was dispatched. */
export class AiConsentBlocked extends Error {
constructor(message = AI_CONSENT_REQUIRED) {
super(message);
}
}
/** Only user actions that can start AI processing need a foreground disclosure. */
export function aiDataUsesForProcedure(procedure: string, input?: unknown): AiDataUse[] {
const path = procedure.replaceAll(".", "/");
if (
path === "routines/update" &&
input &&
typeof input === "object" &&
"active" in input &&
input.active === false
)
return [];
if (
[
"threads/send",
"artifacts/create",
"threads/followUp",
"threads/react",
"threads/answer",
"routines/create",
"routines/update",
"routines/testRun",
].includes(path)
)
return ["model", "memory"];
if (path === "media/jobs/start") return ["model"];
if (["voice/prepare", "voice/speak", "voice/transcribe"].includes(path)) return ["voice"];
return [];
}
export async function ensureAiDataConsent(options: {
uses: AiDataUse[];
status(): Promise<AiConsentStatus>;
prompt(recipient: AiRecipient, privacyUrl?: string): Promise<boolean>;
allow(input: { scope: string; version: string; keys: string[] }): Promise<unknown>;
}) {
if (options.uses.length === 0) return;
try {
const status = await options.status();
for (const recipient of status.recipients) {
if (recipient.allowed || !options.uses.includes(recipient.use)) continue;
if (!(await options.prompt(recipient, status.privacyUrl)))
throw new Error(AI_CONSENT_REQUIRED);
await options.allow({ scope: status.scope, version: status.version, keys: [recipient.key] });
}
} catch (error) {
throw new AiConsentBlocked(error instanceof Error ? error.message : AI_CONSENT_REQUIRED);
}
}
export function aiConsentTarget(input: unknown): { botId?: string; groupId?: string } {
if (!input || typeof input !== "object") return {};
const target = input as { botId?: unknown; groupId?: unknown };
if (typeof target.groupId === "string") return { groupId: target.groupId };
return typeof target.botId === "string" ? { botId: target.botId } : {};
}