/** * Route definitions for secret and credential management. * * POST /v1/secrets — add a secret (API key or credential) * DELETE /v1/secrets — delete a secret * GET /v1/secrets — list all stored secrets * POST /v1/secrets/read — read (masked or revealed) a secret value */ import { z } from "zod"; import { ACP_SERVICE, AcpCredentialFormatError, assertAcpCredentialFormat, } from "../../acp/acp-credentials.js"; import { getPlatformAssistantId, setPlatformAssistantId, setPlatformBaseUrl, setPlatformOrganizationId, setPlatformUserId, } from "../../config/env.js"; import { getConfig, invalidateConfigCache } from "../../config/loader.js"; import { maybeDefaultSpeechToManaged } from "../../config/managed-speech-defaults.js"; import { getCesClient } from "../../credential-execution/ces-runtime.js"; import type { CesClient } from "../../credential-execution/client.js"; import { evictConversationsForReload } from "../../daemon/conversation-store.js"; import { isNonSecretPlatformField, scrubStoredCredentialFromTranscripts, } from "../../daemon/credential-transcript-scrub.js"; import { syncManualTokenConnection } from "../../oauth/manual-token-connection.js"; import { clearEmbeddingBackendCache } from "../../persistence/embeddings/embedding-backend.js"; import { maybeReseedCapabilitiesAfterManagedCredential } from "../../plugins/defaults/memory/substrate/boot-maintenance.js"; import { validateAnthropicApiKey } from "../../providers/anthropic/client.js"; import { validateAtlasCloudApiKey } from "../../providers/atlascloud/client.js"; import { validateBasetenApiKey } from "../../providers/baseten/client.js"; import { validateGeminiApiKey } from "../../providers/gemini/client.js"; import { validateMinimaxApiKey } from "../../providers/minimax/client.js"; import { validateOpenAIApiKey } from "../../providers/openai/client.js"; import { validatePoolsideApiKey } from "../../providers/poolside/client.js"; import { API_KEY_PROVIDERS } from "../../providers/provider-secret-catalog.js"; import { initializeProviders } from "../../providers/registry.js"; import { credentialKey } from "../../security/credential-key.js"; import { deleteSecureKeyAsync, getActiveBackendName, getSecureKeyAsync, getSecureKeyResultAsync, listSecureKeysAsync, setSecureKeyAsync, } from "../../security/secure-keys.js"; import { assertMetadataWritable, deleteCredentialMetadata, upsertCredentialMetadata, } from "../../tools/credentials/metadata-store.js"; import { getLogger } from "../../util/logger.js"; import { ACTOR_PRINCIPALS } from "../auth/route-policy.js"; import { assertCredentialNotInUse, invalidateConnectionsAfterCredentialDelete, } from "./credential-in-use.js"; import { BadRequestError, InternalError, NotFoundError, RouteError, } from "./errors.js"; import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; const log = getLogger("runtime-http"); const MANAGED_PROXY_CREDENTIALS = [ { service: "vellum", field: "assistant_api_key" }, { service: "vellum", field: "platform_base_url" }, ] as const; function isManagedProxyCredential(service: string, field: string): boolean { return MANAGED_PROXY_CREDENTIALS.some( (c) => c.service === service && c.field === field, ); } const CES_READY_POLL_INTERVAL_MS = 500; const CES_READY_POLL_TIMEOUT_MS = 30_000; let apiKeyGeneration = 0; async function queueApiKeyPropagation( cesClient: CesClient, apiKey: string, generation: number, ): Promise { log.info( "CES client not ready — queuing API key propagation until handshake completes", ); const deadline = Date.now() + CES_READY_POLL_TIMEOUT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, CES_READY_POLL_INTERVAL_MS)); if (generation < apiKeyGeneration) { log.info( "Discarding stale queued API key propagation — a newer key was already handled", ); return; } if (cesClient.isReady()) { if (generation < apiKeyGeneration) { log.info( "Discarding stale queued API key propagation — a newer key was already handled", ); return; } try { await cesClient.updateAssistantApiKey( apiKey, getPlatformAssistantId() || undefined, ); log.info( "Notified CES of the queued assistant API key after handshake completed (ack only; CES reads the durable value from the credential store)", ); } catch (err) { log.warn( { error: err instanceof Error ? err.message : String(err) }, "Failed to push queued assistant API key to CES (non-fatal)", ); } return; } } log.warn( "Timed out waiting for CES client to become ready — API key was not propagated", ); } /** * Notify CES that the assistant API key changed. This is advisory: the key is * durably stored in the credential vault and CES reads it from there. When no * CES client is connected the notification is skipped, but the skip is logged * rather than dropped silently. Exported for direct testing. */ export async function notifyCesOfAssistantApiKeyUpdate( value: string, cesClient: CesClient | undefined, logger: Pick, "info" | "warn"> = log, ): Promise { const generation = ++apiKeyGeneration; if (!cesClient) { logger.warn( "Assistant API key updated but no CES client is connected; skipping the CES key-update notification (advisory only; CES reads the durable value from the credential store)", ); return; } if (cesClient.isReady()) { try { await cesClient.updateAssistantApiKey( value, getPlatformAssistantId() || undefined, ); logger.info( "Notified CES of the updated assistant API key (ack only; CES reads the durable value from the credential store)", ); } catch (err) { logger.warn( { error: err instanceof Error ? err.message : String(err) }, "Failed to notify CES of the assistant API key update (non-fatal)", ); } return; } void queueApiKeyPropagation(cesClient, value, generation); } // --------------------------------------------------------------------------- // Provider refresh after secret changes // --------------------------------------------------------------------------- async function refreshProvidersAfterSecretChange(): Promise { clearEmbeddingBackendCache(); invalidateConfigCache(); await initializeProviders(getConfig()); // Provider instances are captured when conversations are created, so a key // change must evict or mark them stale before the next turn. Best-effort: // the credential write has already succeeded, so a disposal failure must not // surface as a 500 that makes clients think the secret change failed. try { evictConversationsForReload(); } catch (err) { log.warn( { error: err instanceof Error ? err.message : String(err) }, "Error evicting conversations after credential change (non-fatal)", ); } } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- async function handleAddSecret({ body }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } const { type, name, value } = body as { type?: string; name?: string; value?: string; }; if (!type || typeof type !== "string") { throw new BadRequestError("type is required"); } if (!name || typeof name !== "string") { throw new BadRequestError("name is required"); } if (!value || typeof value !== "string") { throw new BadRequestError("value is required"); } try { if (type === "api_key") { if ( !API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number]) ) { throw new BadRequestError( `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`, ); } if (name === "anthropic") { const validation = await validateAnthropicApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } else if (name === "openai") { const validation = await validateOpenAIApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } else if (name === "gemini") { const validation = await validateGeminiApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } else if (name === "minimax") { const validation = await validateMinimaxApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } else if (name === "atlascloud") { const validation = await validateAtlasCloudApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } else if (name === "baseten") { const validation = await validateBasetenApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } else if (name === "poolside") { const validation = await validatePoolsideApiKey(value); if (!validation.valid) { log.warn( { provider: name, reason: validation.reason }, "API key validation failed", ); return { success: false, error: validation.reason }; } } const stored = await setSecureKeyAsync( credentialKey(name, "api_key"), value, ); if (!stored) { throw new InternalError( `Failed to store API key in secure storage (backend: ${getActiveBackendName()})`, ); } // The stored plaintext may already sit in recent transcripts (a pasted // key echoed by a tool result or persisted tool_use input). The scrub // runs immediately after the secure-store write, BEFORE the provider // refresh: that side effect can throw, and a stored-but-unscrubbed key // must not depend on it succeeding. The key IS stored at this point; // the scrub is best-effort hygiene and must stay invisible to the // caller. Counts only — never the value. try { const scrubbed = await scrubStoredCredentialFromTranscripts(value); log.info( { provider: name, ...scrubbed }, "API key stored; scrubbed value from recent transcripts", ); } catch (err) { log.warn( { err, provider: name }, "API key stored, but transcript scrub failed", ); } await refreshProvidersAfterSecretChange(); log.info({ provider: name }, "API key updated via HTTP"); return { success: true, type, name }; } if (type === "credential") { const colonIdx = name.lastIndexOf(":"); if (colonIdx < 1 || colonIdx === name.length - 1) { throw new BadRequestError( 'For credential type, name must be in "service:field" format (e.g. "github:api_token")', ); } assertMetadataWritable(); const service = name.slice(0, colonIdx); const field = name.slice(colonIdx + 1); // Reject an Anthropic API key pasted into the ACP OAuth-token field (a 401 // footgun) as a 400 rather than letting it persist and fail at runtime. if (service === ACP_SERVICE) { try { assertAcpCredentialFormat(field, value); } catch (err) { if (err instanceof AcpCredentialFormatError) { throw new BadRequestError(err.message); } throw err; } } const key = credentialKey(service, field); const TRIMMED_IDENTITY_FIELDS = new Set([ "platform_assistant_id", "platform_organization_id", "platform_user_id", ]); const isTrimmedIdentity = service === "vellum" && TRIMMED_IDENTITY_FIELDS.has(field); const effectiveValue = isTrimmedIdentity ? value.trim() : value; if (isTrimmedIdentity && effectiveValue === "") { const deleteResult = await deleteSecureKeyAsync(key); if (deleteResult === "error") { throw new InternalError( `Failed to delete stale credential from secure storage: ${service}:${field}`, ); } if (field === "platform_assistant_id") { setPlatformAssistantId(undefined); } else if (field === "platform_organization_id") { setPlatformOrganizationId(undefined); } else if (field === "platform_user_id") { setPlatformUserId(undefined); } deleteCredentialMetadata(service, field); } else { const stored = await setSecureKeyAsync(key, effectiveValue); if (!stored) { throw new InternalError( `Failed to store credential in secure storage (backend: ${getActiveBackendName()})`, ); } if (!isNonSecretPlatformField(service, field)) { // Same seam as the api_key branch: the scrub runs immediately after // the secure-store write, before side effects that can throw. The // value IS stored at this point; the scrub is best-effort hygiene // and must stay invisible to the caller. Counts only — never the // value. try { const scrubbed = await scrubStoredCredentialFromTranscripts(effectiveValue); log.info( { service, field, ...scrubbed }, "Credential stored; scrubbed value from recent transcripts", ); } catch (err) { log.warn( { err, service, field }, "Credential stored, but transcript scrub failed", ); } } upsertCredentialMetadata(service, field, {}); await syncManualTokenConnection(service); if (service === "vellum" && field === "platform_base_url") { setPlatformBaseUrl(effectiveValue); } if (service === "vellum" && field === "platform_assistant_id") { setPlatformAssistantId(effectiveValue || undefined); } if (service === "vellum" && field === "platform_organization_id") { setPlatformOrganizationId(effectiveValue || undefined); } if (service === "vellum" && field === "platform_user_id") { setPlatformUserId(effectiveValue || undefined); } } if (isManagedProxyCredential(service, field)) { await refreshProvidersAfterSecretChange(); // Close the first-boot race where the startup capability seed ran before // the managed embedding credential was provisioned, leaving skill/CLI // pages unseeded until restart. Detached — must not block the response. void maybeReseedCapabilitiesAfterManagedCredential(getConfig()); if (service === "vellum" && field === "assistant_api_key") { await notifyCesOfAssistantApiKeyUpdate(value, getCesClient()); } } if ( service === "vellum" && (field === "assistant_api_key" || field === "platform_assistant_id" || field === "platform_base_url") ) { // Managed-speech availability needs the API key, the assistant ID, // and the base URL, and the CLI connect path stores all three // concurrently — fire on each so the last write to land triggers the // defaulting; the hook no-ops until the connection is complete. // Detached — must not block the response. void maybeDefaultSpeechToManaged(); } log.info({ service, field }, "Credential added via HTTP"); return { success: true, type, name }; } throw new BadRequestError( `Unknown secret type: ${type}. Valid types: api_key, credential`, ); } catch (err) { if ( err instanceof BadRequestError || err instanceof InternalError || err instanceof NotFoundError ) { throw err; } const message = err instanceof Error ? err.message : String(err); log.error({ err, type, name }, "Failed to add secret via HTTP"); throw new InternalError(message); } } async function handleReadSecret({ body }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } const { type, name, reveal } = body as { type?: string; name?: string; reveal?: boolean; }; if (!type || typeof type !== "string") { throw new BadRequestError("type is required"); } if (!name || typeof name !== "string") { throw new BadRequestError("name is required"); } try { let accountKey: string; let prefetchedResult: | Awaited> | undefined; if (type === "api_key") { if ( !API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number]) ) { throw new BadRequestError( `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`, ); } // Check credential namespace first; fall back to bare key only if not found and store is reachable. const credResult = await getSecureKeyResultAsync( credentialKey(name, "api_key"), ); if (credResult.value === undefined && !credResult.unreachable) { accountKey = name; } else { accountKey = credentialKey(name, "api_key"); prefetchedResult = credResult; } } else if (type === "credential") { const colonIdx = name.lastIndexOf(":"); if (colonIdx < 1 || colonIdx === name.length - 1) { throw new BadRequestError( 'For credential type, name must be in "service:field" format (e.g. "github:api_token")', ); } const service = name.slice(0, colonIdx); const field = name.slice(colonIdx + 1); accountKey = credentialKey(service, field); } else { throw new BadRequestError( `Unknown secret type: ${type}. Valid types: api_key, credential`, ); } const { value, unreachable } = prefetchedResult ?? (await getSecureKeyResultAsync(accountKey)); if (value === undefined) { return { found: false, unreachable }; } if (reveal) { return { found: true, value, unreachable: false }; } const minHidden = 3; const maxVisible = Math.max(1, value.length - minHidden); const prefixLen = Math.min(10, maxVisible); const suffixLen = Math.min(4, Math.max(0, maxVisible - prefixLen)); const masked = `${value.slice(0, prefixLen)}...${suffixLen > 0 ? value.slice(-suffixLen) : ""}`; return { found: true, masked, unreachable: false }; } catch (err) { if ( err instanceof BadRequestError || err instanceof InternalError || err instanceof NotFoundError ) { throw err; } const message = err instanceof Error ? err.message : String(err); log.error({ err, type, name }, "Failed to read secret via HTTP"); throw new InternalError(message); } } async function handleDeleteSecret({ body }: RouteHandlerArgs) { if (!body || typeof body !== "object") { throw new BadRequestError("Request body is required"); } const { type, name, force } = body as { type?: string; name?: string; force?: boolean; }; if (!type || typeof type !== "string") { throw new BadRequestError("type is required"); } if (!name || typeof name !== "string") { throw new BadRequestError("name is required"); } try { if (type === "api_key") { if ( !API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number]) ) { throw new BadRequestError( `Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`, ); } const credKey = credentialKey(name, "api_key"); const credResult = await getSecureKeyResultAsync(credKey); if (credResult.unreachable) { throw new InternalError("Credential store is unreachable"); } // Confirm at least one location holds the key before reporting success. if (credResult.value === undefined) { const bareResult = await getSecureKeyResultAsync(name); if (bareResult.unreachable) { throw new InternalError("Credential store is unreachable"); } if (bareResult.value === undefined) { throw new NotFoundError(`API key not found: ${name}`); } } // Provider connections reference the namespaced account, so the in-use // check keys off `credKey`: the bare pre-migration account is never a // connection's `resolve_auth` target. const affectedConnections = assertCredentialNotInUse( credKey, force === true, ); // Delete from both locations. During a migration overlap both may exist; // ignore "not-found" since one location may already be empty. const credDeleteResult = await deleteSecureKeyAsync(credKey); if (credDeleteResult === "error") { throw new InternalError( `Failed to delete API key from secure storage: ${name}`, ); } const bareDeleteResult = await deleteSecureKeyAsync(name); if (bareDeleteResult === "error") { throw new InternalError( `Failed to delete API key from secure storage: ${name}`, ); } await refreshProvidersAfterSecretChange(); invalidateConnectionsAfterCredentialDelete(affectedConnections); log.info({ provider: name }, "API key deleted via HTTP"); return { success: true, type, name }; } if (type === "credential") { const colonIdx = name.lastIndexOf(":"); if (colonIdx < 1 || colonIdx === name.length - 1) { throw new BadRequestError( 'For credential type, name must be in "service:field" format (e.g. "github:api_token")', ); } const service = name.slice(0, colonIdx); const field = name.slice(colonIdx + 1); assertMetadataWritable(); const key = credentialKey(service, field); const existing = await getSecureKeyAsync(key); if (existing === undefined) { throw new NotFoundError(`Credential not found: ${name}`); } const affectedConnections = assertCredentialNotInUse(key, force === true); const deleteResult = await deleteSecureKeyAsync(key); if (deleteResult === "error") { throw new InternalError( `Failed to delete credential from secure storage: ${name}`, ); } deleteCredentialMetadata(service, field); if (service === "vellum" && field === "platform_base_url") { setPlatformBaseUrl(undefined); } if (service === "vellum" && field === "platform_assistant_id") { setPlatformAssistantId(undefined); } if (service === "vellum" && field === "platform_organization_id") { setPlatformOrganizationId(undefined); } if (service === "vellum" && field === "platform_user_id") { setPlatformUserId(undefined); } if (isManagedProxyCredential(service, field)) { await refreshProvidersAfterSecretChange(); } invalidateConnectionsAfterCredentialDelete(affectedConnections); log.info({ service, field }, "Credential deleted via HTTP"); return { success: true, type, name }; } throw new BadRequestError( `Unknown secret type: ${type}. Valid types: api_key, credential`, ); } catch (err) { // Every RouteError is already a deliberate client-facing outcome (including // CREDENTIAL_IN_USE); re-wrapping one as a 500 would hide its code, status, // and details from the caller. if (err instanceof RouteError) { throw err; } const message = err instanceof Error ? err.message : String(err); log.error({ err, type, name }, "Failed to delete secret via HTTP"); throw new InternalError(message); } } const CREDENTIAL_KEY_PREFIX = "credential/"; async function handleListSecrets() { try { const { accounts, unreachable } = await listSecureKeysAsync(); if (unreachable) { throw new InternalError("Credential store is unreachable"); } // First pass: collect provider names already stored under the credential/ // namespace. Used to deduplicate bare-key entries that co-exist when a // crash interrupted migration 002 between the set(credentialKey) and // delete(provider) steps. const credentialNamespaceProviders = new Set( accounts.flatMap((account) => { if (!account.startsWith(CREDENTIAL_KEY_PREFIX)) { return []; } const rest = account.slice(CREDENTIAL_KEY_PREFIX.length); const slashIdx = rest.indexOf("/"); if (slashIdx < 1 || slashIdx >= rest.length - 1) { return []; } const service = rest.slice(0, slashIdx); const field = rest.slice(slashIdx + 1); if ( field === "api_key" && API_KEY_PROVIDERS.includes( service as (typeof API_KEY_PROVIDERS)[number], ) ) { return [service]; } return []; }), ); const secrets = accounts .filter((account) => { // Drop bare-key entries for providers already represented via the // credential/ namespace to prevent duplicates after a partial migration. if (account.startsWith(CREDENTIAL_KEY_PREFIX)) { return true; } if (credentialNamespaceProviders.has(account)) { return false; } return true; }) .map((account) => { if (account.startsWith(CREDENTIAL_KEY_PREFIX)) { const rest = account.slice(CREDENTIAL_KEY_PREFIX.length); const slashIdx = rest.indexOf("/"); if (slashIdx > 0 && slashIdx < rest.length - 1) { const service = rest.slice(0, slashIdx); const field = rest.slice(slashIdx + 1); // api_key entries are stored as credential/{provider}/api_key if ( field === "api_key" && API_KEY_PROVIDERS.includes( service as (typeof API_KEY_PROVIDERS)[number], ) ) { return { type: "api_key" as const, name: service }; } return { type: "credential" as const, name: `${service}:${field}`, }; } } // Bare keys (pre-migration or unknown): treat as api_key return { type: "api_key" as const, name: account }; }); return { secrets, accounts: secrets }; } catch (err) { if ( err instanceof BadRequestError || err instanceof InternalError || err instanceof NotFoundError ) { throw err; } const message = err instanceof Error ? err.message : String(err); throw new InternalError(message); } } // --------------------------------------------------------------------------- // Route definitions (shared HTTP + IPC) // --------------------------------------------------------------------------- export const ROUTES: RouteDefinition[] = [ { operationId: "secrets_add", endpoint: "secrets", method: "POST", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Add a secret", description: "Store a new secret (API key, OAuth token, etc.) in the credential vault.", tags: ["secrets"], requestBody: z.object({ type: z.string().describe("Secret type: 'api_key' or 'credential'"), name: z.string().describe("Unique name for the secret"), value: z.string().describe("Secret value to store"), }), responseBody: z.object({ success: z.boolean(), type: z.string().optional(), name: z.string().optional(), error: z .string() .optional() .describe( "Why the secret was not stored (e.g. provider-side API key validation failed). Present only when success is false.", ), }), handler: handleAddSecret, }, { operationId: "secrets_delete", endpoint: "secrets", method: "DELETE", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Delete a secret", description: "Remove a secret from the credential vault by name. Refused with " + "CREDENTIAL_IN_USE while an LLM provider connection resolves its auth " + "through the credential, unless `force` is set.", tags: ["secrets"], requestBody: z.object({ type: z.string().describe("Secret type: 'api_key' or 'credential'"), name: z.string().describe("Name of the secret to delete"), force: z .boolean() .optional() .describe( "Delete even when provider connections depend on the credential", ), }), responseBody: z.object({ success: z.boolean(), type: z.string(), name: z.string(), }), handler: handleDeleteSecret, }, { operationId: "secrets_list", endpoint: "secrets", method: "GET", policy: { requiredScopes: ["settings.read"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "List secrets", description: "Return the names (not values) of all stored secrets.", tags: ["secrets"], responseBody: z.object({ secrets: z .array( z.object({ type: z.enum(["api_key", "credential"]), name: z.string(), }), ) .describe("List of secret metadata entries, each with type and name"), accounts: z .array( z.object({ type: z.enum(["api_key", "credential"]), name: z.string(), }), ) .describe("Alias for secrets (same data)"), }), handler: handleListSecrets, }, { operationId: "secrets_read", endpoint: "secrets/read", method: "POST", policy: { requiredScopes: ["settings.write"], allowedPrincipalTypes: ACTOR_PRINCIPALS, }, summary: "Read a secret value", description: "Retrieve the decrypted value of a stored secret by name.", tags: ["secrets"], requestBody: z.object({ type: z.string().describe("Secret type: 'api_key' or 'credential'"), name: z.string().describe("Name of the secret to read"), reveal: z .boolean() .describe( "If true, return the decrypted value; otherwise return a masked version", ) .optional(), }), responseBody: z.object({ found: z.boolean(), value: z .string() .describe("Decrypted value (only when reveal=true and found)"), masked: z.string().describe("Masked value (when reveal=false and found)"), unreachable: z.boolean(), }), handler: handleReadSecret, }, ];