Files
elizaos--eliza/plugins/plugin-cloud-apps/src/safety.ts
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

516 lines
18 KiB
TypeScript

/**
* Shared safety primitives for the plugin's destructive + paid actions.
*
* ── Two-phase confirm (connector-agnostic) ───────────────────────────────────
* A destructive or paid action NEVER acts on the first ask. On the first turn it
* returns a confirmation prompt that names the exact target and stores a pending
* confirmation task. It acts only when a later turn carries the planner's
* structured `confirm: true` boolean for that pending task. The handler never
* authorizes money/security/destructive work by matching the user's prose, so
* non-English confirmations depend on the planner's structured extraction
* instead of English keyword banks.
*
* ── Connector-agnostic CTA (for the paid actions) ────────────────────────────
* Paid actions that must hand the user off to a browser (withdraw earnings, buy
* a domain) build a neutral {label,url,kind} object the connector renders
* however it can (Discord link button, Telegram URL button, in-app card). Money
* and credentials NEVER transit the connector: the CTA carries only a human
* label plus an https URL the user opens themselves. {@link buildConnectorCta}
* is the single seam those actions reuse.
*/
import type { IAgentRuntime, Memory, UUID } from "@elizaos/core";
import { logger } from "@elizaos/core";
/** How a connector should render a call-to-action handed back by an action. */
export type CtaKind = "link" | "button" | "card";
/**
* A neutral call-to-action a connector renders. Carries ONLY a label + URL —
* never a token, secret, signed payload, or money amount. The user completes
* any payment/credential step in the browser the URL opens.
*/
export interface ConnectorCta {
label: string;
url: string;
kind: CtaKind;
}
/** The thing a destructive/paid action is about to act on. */
export interface ConfirmTarget {
/** Human-facing label, e.g. the app name. */
name: string;
/** Stable id, e.g. the app id (matched verbatim when present). */
id?: string;
/** Other strings that also identify the target (slug, etc.). */
aliases?: string[];
}
export type CloudAppConfirmationAction =
| "BUY_APP_DOMAIN"
| "DELETE_APP"
| "REGENERATE_APP_API_KEY"
| "WITHDRAW_APP_EARNINGS"
| "BOOK_INFLUENCER"
| "SUBMIT_PRESS_RELEASE";
export const CLOUD_APP_CONFIRM_TAG = "cloud-apps-confirm";
export interface CloudAppConfirmationMetadata {
roomId: string;
action: CloudAppConfirmationAction;
appId: string;
appName: string;
appSlug?: string;
amount?: number;
/**
* The confirmed charge in integer USD cents (BUY_APP_DOMAIN) — compared
* exactly against the re-checked price at purchase time so the server never
* debits a price the user did not confirm.
*/
amountUsdCents?: number;
/** The exact domain a pending BUY_APP_DOMAIN confirmation is for. */
domain?: string;
/**
* True when the pending is a recovery retry of a money move whose earlier
* attempt already (or may already) have committed server-side:
* BUY_APP_DOMAIN — the purchase charged + registered but failed to attach
* (the server finishes it without a new charge); BOOK_INFLUENCER — the fund
* call failed at the transport level, so the escrow may already be held and
* the pending's taskId is the sole holder of the idempotency key the server
* dedupes/resumes on. Recovery retries complete without a second charge.
*/
recovery?: boolean;
cta?: ConnectorCta;
intentCreatedAt?: string;
/**
* BOOK_INFLUENCER only: the campaign brief for the pending booking. (For that
* action `appId`/`appName` carry the influencer profile id + display name.)
*/
brief?: string;
}
export interface PendingCloudAppConfirmation {
taskId: string;
metadata: CloudAppConfirmationMetadata;
}
/**
* A pending money confirmation is honored for this long; after that the gated
* action refuses a bare confirm and asks the user to re-state the intent.
* Shared by every gated action so a stale pending can never fund a
* booking/purchase the user has long forgotten about.
*/
export const CONFIRM_TTL_MS = 15 * 60 * 1000;
/**
* True when a pending confirmation is older than {@link CONFIRM_TTL_MS}.
*
* A recovery retry never expires: it completes with no new charge — there is
* no stale price to protect, and expiring it would strand money already
* committed server-side (a paid, unattached domain; an influencer escrow whose
* idempotency key only the pending still holds).
*/
export function pendingExpired(
pending: PendingCloudAppConfirmation,
now: number = Date.now(),
): boolean {
if (pending.metadata.recovery === true) return false;
const at =
typeof pending.metadata.intentCreatedAt === "string"
? Date.parse(pending.metadata.intentCreatedAt)
: Number.NaN;
if (!Number.isFinite(at)) return false;
return now - at > CONFIRM_TTL_MS;
}
export function readStructuredConfirmation(options?: unknown): boolean | null {
if (!options || typeof options !== "object") return null;
const opts = options as Record<string, unknown>;
// Validated action parameters arrive nested under `options.parameters` on the
// real planner path (execute-planned-tool-call.ts sets `handlerOptions.parameters
// = validation.args`); only direct handler calls / scenario `action`-kind turns
// place them at the top level. Read the nested location first, then fall back.
const params =
opts.parameters && typeof opts.parameters === "object"
? (opts.parameters as Record<string, unknown>)
: undefined;
const value =
params?.confirm ?? params?.confirmed ?? opts.confirm ?? opts.confirmed;
if (value === true || value === false) return value;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return null;
}
// ─── Confirm-turn target consistency (the "frozen target" guard) ─────────────
//
// The gated actions execute the params FROZEN at the first ask — the confirm
// turn is never re-parsed for new work. But when the confirm turn's own
// structured params clearly name a DIFFERENT target ("yes — delete Beta
// Dashboard" while the pending delete is for "Acme Bot"), executing the frozen
// target acts on something the user is no longer talking about. These helpers
// detect that conflict so the action refuses + clears the pending instead of
// mutating. They are deliberately lenient: a bare confirm, generic filler
// ("my app"), or a partial name of the SAME target never blocks.
/**
* Planner-option keys that may carry the confirm turn's own app reference.
* Mirrors the reference keys the actions resolve with at the first ask, minus
* `query` (which can carry loose prose that must not read as a target switch).
*/
export const CONFIRM_APP_REFERENCE_KEYS = [
"app",
"appName",
"name",
"id",
"appId",
] as const;
/** Filler words that alone never name a specific target ("my app", "it"). */
const GENERIC_REFERENCE_WORDS = new Set([
"my",
"the",
"this",
"that",
"our",
"your",
"it",
"its",
"app",
"apps",
"application",
"one",
"domain",
"influencer",
"creator",
"profile",
"booking",
"key",
"earnings",
]);
function normalizeReference(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, " ");
}
/** True when the reference is generic filler that names no specific target. */
function isGenericReference(reference: string): boolean {
return normalizeReference(reference)
.split(" ")
.every((word) => word.length === 0 || GENERIC_REFERENCE_WORDS.has(word));
}
/**
* The confirm turn's own structured target reference, when the planner sent
* one alongside `confirm` (nested `options.parameters` first — the real
* planner path — then top-level). Null = a bare confirm with no reference.
*/
export function readConfirmTurnReference(
options: unknown,
keys: readonly string[],
): string | null {
if (!options || typeof options !== "object") return null;
const top = options as Record<string, unknown>;
const nested =
top.parameters && typeof top.parameters === "object"
? (top.parameters as Record<string, unknown>)
: undefined;
for (const source of nested ? [nested, top] : [top]) {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
}
return null;
}
/**
* True when `reference` plausibly names the frozen target: its id verbatim, or
* a normalized exact/containment match on the name or an alias. Lenient on
* purpose — a partial name ("acme" for "Acme Bot") or generic filler ("my
* app") must NOT read as a target switch; only a clearly different name does.
*/
export function confirmReferenceMatchesTarget(
reference: string,
target: ConfirmTarget,
): boolean {
const ref = normalizeReference(reference);
if (ref.length === 0 || isGenericReference(reference)) return true;
if (typeof target.id === "string" && normalizeReference(target.id) === ref) {
return true;
}
const names = [target.name, ...(target.aliases ?? [])]
.filter(
(name): name is string =>
typeof name === "string" && name.trim().length > 0,
)
.map(normalizeReference);
return names.some(
(name) => name === ref || name.includes(ref) || ref.includes(name),
);
}
/**
* The conflicting reference when the confirm turn's structured params name a
* DIFFERENT target than the frozen pending snapshot — the gated action must
* refuse (and clear the pending) instead of executing the frozen target. Null
* = consistent: a bare confirm, or a reference matching the frozen target.
*/
export function conflictingConfirmTarget(
options: unknown,
target: ConfirmTarget,
keys: readonly string[] = CONFIRM_APP_REFERENCE_KEYS,
): string | null {
const reference = readConfirmTurnReference(options, keys);
if (reference === null) return null;
return confirmReferenceMatchesTarget(reference, target) ? null : reference;
}
/**
* The conflicting amount when the confirm turn's structured params carry a
* numeric amount that differs from the frozen one (e.g. "confirm — but only
* $50" against a pending $100 withdrawal). Only trusts an unambiguous number
* (a number, or a plain numeric string with an optional leading `$`); prose
* never blocks. Null = consistent or no amount sent.
*/
export function conflictingConfirmAmount(
options: unknown,
frozenAmount: number,
): number | null {
if (!options || typeof options !== "object") return null;
const top = options as Record<string, unknown>;
const nested =
top.parameters && typeof top.parameters === "object"
? (top.parameters as Record<string, unknown>)
: undefined;
const value = nested?.amount ?? top.amount;
let amount: number | null = null;
if (typeof value === "number" && Number.isFinite(value)) {
amount = value;
} else if (typeof value === "string") {
const cleaned = value.trim().replace(/^\$/, "");
if (/^\d+(\.\d+)?$/.test(cleaned)) amount = Number(cleaned);
}
if (amount === null) return null;
return Math.abs(amount - frozenAmount) < 0.005 ? null : amount;
}
/**
* The conflicting domain when the confirm turn's structured `domain` param
* names a DIFFERENT domain than the frozen pending purchase. Domains are exact
* identifiers, so unlike app names this is an exact comparison
* (case-insensitive, ignoring a leading "www." and a trailing dot); values
* that don't look like a domain never block. Null = consistent or none sent.
*/
export function conflictingConfirmDomain(
options: unknown,
frozenDomain: string,
): string | null {
const reference = readConfirmTurnReference(options, ["domain"]);
if (reference === null) return null;
const normalize = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/\.$/, "")
.replace(/^www\./, "");
const turn = normalize(reference);
if (!turn.includes(".")) return null;
return turn === normalize(frozenDomain) ? null : reference;
}
/**
* The shared refusal copy for a confirm-turn target/amount conflict. Truthful:
* nothing was executed and the pending confirmation has been cleared by the
* caller before replying.
*/
export function confirmTargetMismatchMessage(
requested: string,
what: string,
pendingName: string,
): string {
return (
`Your confirmation names "${requested}", but the pending ${what} was for "${pendingName}". ` +
`To be safe I did nothing, and that pending confirmation is now cleared. ` +
`Re-state what you want and I'll ask you to confirm again.`
);
}
export function confirmationRoomId(
runtime: IAgentRuntime,
message: Memory,
): string {
if (typeof message.roomId === "string" && message.roomId.length > 0) {
return message.roomId;
}
return String(runtime.agentId ?? "cloud-apps-default-room");
}
function isCloudAppConfirmationMetadata(
metadata: Record<string, unknown> | undefined,
roomId: string,
action: CloudAppConfirmationAction,
): metadata is Record<string, unknown> & CloudAppConfirmationMetadata {
return (
metadata?.roomId === roomId &&
metadata.action === action &&
typeof metadata.appId === "string" &&
typeof metadata.appName === "string"
);
}
export async function findPendingCloudAppConfirmation(
runtime: IAgentRuntime,
roomId: string,
action: CloudAppConfirmationAction,
): Promise<PendingCloudAppConfirmation | null> {
const tasks = await runtime.getTasks({
agentIds: [runtime.agentId],
tags: [CLOUD_APP_CONFIRM_TAG],
});
const matching = tasks
.map((task): PendingCloudAppConfirmation | null => {
const metadata = task.metadata as Record<string, unknown> | undefined;
if (
!task.id ||
!isCloudAppConfirmationMetadata(metadata, roomId, action)
) {
return null;
}
return {
taskId: task.id,
metadata,
};
})
.filter((task): task is PendingCloudAppConfirmation => task !== null)
.sort((a, b) => {
const aAt =
typeof a.metadata.intentCreatedAt === "string"
? Date.parse(a.metadata.intentCreatedAt) || 0
: 0;
const bAt =
typeof b.metadata.intentCreatedAt === "string"
? Date.parse(b.metadata.intentCreatedAt) || 0
: 0;
return bAt - aAt;
});
return matching[0] ?? null;
}
export async function persistCloudAppConfirmation(
runtime: IAgentRuntime,
metadata: CloudAppConfirmationMetadata,
): Promise<void> {
await runtime.createTask({
name: `${metadata.action} confirm`,
description: `Awaiting user confirmation for ${metadata.action}: ${metadata.appName}`,
tags: [CLOUD_APP_CONFIRM_TAG],
metadata: {
...metadata,
intentCreatedAt: metadata.intentCreatedAt ?? new Date().toISOString(),
},
});
}
/**
* Mark an existing pending confirmation as a recovery retry IN PLACE.
*
* The task is updated, never deleted + re-created: for BOOK_INFLUENCER the
* taskId is the escrow idempotency key (`influencer-confirm-<taskId>`), so a
* re-created task would mint a new key and let a user retry fund a SECOND
* escrow instead of resuming the first (#11844). Failures are logged and
* swallowed — the pending (and its key) survives either way; only the TTL
* exemption is best-effort.
*/
export async function markCloudAppConfirmationRecovery(
runtime: IAgentRuntime,
pending: PendingCloudAppConfirmation,
): Promise<void> {
try {
await runtime.updateTask(pending.taskId as UUID, {
metadata: { ...pending.metadata, recovery: true },
});
} catch (err) {
logger.warn(
`[plugin-cloud-apps] failed to mark confirm task ${pending.taskId} as recovery: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
export async function deleteCloudAppConfirmation(
runtime: IAgentRuntime,
taskId: string,
): Promise<void> {
await runtime
.deleteTask(taskId as UUID)
.catch((err) =>
logger.warn(
`[plugin-cloud-apps] failed to delete confirm task ${taskId}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
/**
* Build the first-phase confirmation prompt for a destructive action.
*
* Names the exact target (+ id when present), lists what is destroyed, and tells
* the user the exact token to send back. `verb` defaults to "delete".
*/
export function confirmationPrompt(
target: ConfirmTarget,
destroys: string[],
verb = "delete",
): string {
const label = target.id
? `"${target.name}" (${target.id})`
: `"${target.name}"`;
const destroyClause =
destroys.length > 0
? ` This permanently destroys ${joinList(destroys)}.`
: "";
return (
`This will ${verb} ${label}.${destroyClause} This can't be undone. ` +
`To go ahead, reply that you confirm ${verb} ${target.name}.`
);
}
function joinList(items: string[]): string {
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
/**
* Build a neutral connector CTA. The seam the DEFERRED paid actions reuse so a
* connector can surface a "complete in browser" affordance. Throws if the URL is
* not an http(s) URL — money/credentials must never be smuggled into the CTA.
*/
export function buildConnectorCta(
label: string,
url: string,
kind: CtaKind = "link",
): ConnectorCta {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`buildConnectorCta: invalid URL "${url}"`);
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error(
`buildConnectorCta: refusing non-http(s) URL "${parsed.protocol}"`,
);
}
return { label, url: parsed.toString(), kind };
}