/** * Structured audit logging for tool calls (#370). * * Coolify's own MCP audit-logs every call and every denial. We logged one line * per *request* in HTTP mode and nothing at all over stdio, which answers "what * was asked" but not "what happened" — and "what did the agent actually do, and * when" is the first question a security review asks about an agent with * production credentials. * * ## What is in a line, and what is deliberately not * * Tool name, action, the resource uuids, the outcome, how long it took, and in * HTTP mode the OAuth client id. **Never arguments, never responses.** That is * not a style preference: `env_vars` create carries secret values in its * arguments, and responses carry everything the central sanitizer exists to * mask. An audit log that quietly became a second copy of the secrets would be * a worse leak than the one it was written to prevent, because nobody reads it * expecting secrets. * * Values are therefore never read out of arguments generically. `pickUuids` * works from a closed allowlist of identifier-shaped keys and validates the * shape of what it finds, so a new tool argument called `password` cannot end * up in a log line by being added upstream of this file. A future argument * genuinely worth auditing has to be added to the allowlist on purpose. * * ## Known limit * * A call whose arguments fail the tool's own schema is rejected by the SDK * before any code here runs, so it produces no line. There is no public seam to * audit from earlier than the tool callback without reaching into SDK * internals. The gap is narrow — nothing executed and no credential was used — * but it is real, and `audit.test.ts` pins it so that it fails, visibly, if the * SDK ever grows the hook that would close it. * * ## Defaults * * On in HTTP mode, off over stdio. A local single-user pipe writing a line to * stderr for every call is noise for most people, while a multi-client * internet-facing server is exactly where the record matters. Either default is * overridden by `COOLIFY_MCP_AUDIT=on|off`. */ /** What happened. `refused` is a decision, `error` is a fault — see {@link AuditRefusal}. */ export type AuditOutcome = 'ok' | 'error' | 'refused' /** * The call raised a confirmation and has not been answered yet (#341). * * Protocol revision 2026-07-28 answers a guarded call with `input_required` * and the client retries it, so one guarded operation writes two lines. The * first one is a question, not a result: recording it as `ok` would report a * destructive tool call as having succeeded when nothing ran, and anyone * counting successful destructive operations would double every one. * * Worth counting in its own right — a confirmation raised and never answered * is a thing an operator wants to be able to see. */ | 'awaiting_confirmation'; /** * Why a call was refused, as a category rather than prose. * * Categories, not messages: a reviewer counting "how often does this server * refuse because nobody could be asked" needs to group these, and free text * does not group. */ export type AuditRefusal = /** A human saw the confirmation and actively said no. */ 'declined' /** A human dismissed the confirmation without answering it. */ | 'cancelled' /** HTTP mode, client cannot elicit at all, so the guard failed closed. */ | 'no_elicitation' /** * The client was asked and no answer could be obtained: a timeout, a * cancelled call, a transport failure, or a client that advertised the * capability and then refused the request. * * Distinct from `declined` on purpose. Someone reading this log to answer * "did a human approve this?" gets the wrong answer if a failed ask is * recorded as a refusal by a person who was never asked (#408). */ | 'unavailable' /** The estate changed between the confirmation and the answer (#341). */ | 'stale_confirmation' /** Arguments did not satisfy the handler (missing uuid, unknown instance). */ | 'validation'; export interface AuditEntry { audit: 'tools/call'; at: string; tool: string; /** The `action` discriminator, when the tool has one. */ action?: string; /** Resource identifiers from a closed allowlist. Never other argument values. */ uuids?: string[]; /** Fleet mode: which instance the call was routed to. */ instance?: string; outcome: AuditOutcome; reason?: AuditRefusal; duration_ms: number; /** HTTP mode only: the OAuth client that made the call. */ client_id?: string; } export declare function pickAction(args: unknown): string | undefined; export declare function pickUuids(args: unknown): string[] | undefined; /** `COOLIFY_MCP_AUDIT` wins over the transport's default in both directions. */ export declare function auditEnabled(defaultOn: boolean, env?: NodeJS.ProcessEnv): boolean; /** Record that this call was refused, and why. No-op outside an audited call. */ export declare function markRefused(reason: AuditRefusal): void; /** Write one line. stderr, so stdout stays a clean JSON-RPC pipe over stdio. */ export declare function writeAudit(entry: AuditEntry): void; interface AuditedCallInput { tool: string; args: unknown; instance?: string; clientId?: string; now?: () => number; write?: (entry: AuditEntry) => void; } /** * Run a tool call and write exactly one audit line for it. * * One line per call, whatever happens, including a throw — a record with holes * where the failures were is worse than no record, because it reads as complete. */ export declare function auditedCall(input: AuditedCallInput, run: () => Promise | T): Promise; export {};