Ctrl K
ComunidadGuías…

server/auto-approve.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Nothing here decides whether an action is safe: each level is the provider's own permission mode passed through, and a
// request reaching this process is one the provider left for a person. The app's own grants are Full access, the person's
// exact saved commands and remembered MCP read tools, Approve for me's web search, and a bot's plain reads of its own files
// at every level (own-file-reads.ts). A bot's question always reaches a human.

import { supportsApprovalMode, type ApprovalMode } from "../shared/approval-mode.ts";
import { failedTurnTool, type FailedTurnTool } from "../shared/failed-turn.ts";
import { isOutboundTool } from "../shared/outbound.ts";
import type { ProviderAdapter, RequestOutcome } from "./contracts.ts";

/** An expired ask must never become a fresh Allow/Deny card. */
export async function deliverFullAccessApproval(
  adapter: Pick<ProviderAdapter, "respondToRequest" | "interruptTurn"> | undefined,
  threadId: string,
  requestId: string,
  turnId?: string,
  isCurrent: () => boolean = () => false,
): Promise<RequestOutcome | "failed"> {
  if (!adapter) return "failed";
  try {
    return await adapter.respondToRequest(threadId, requestId, { behavior: "allow" });
  } catch {
    // Some adapters ignore the native turn id, so recheck the owning generation before interrupting.
    if (turnId && isCurrent()) await adapter.interruptTurn(threadId, turnId).catch(() => {});
    return "failed";
  }
}

/** Full access is the person's grant to this receiving bot: never inherited from a sender (delegatedApprovalMode aside), and
 * peer-started Custom turns run as Auto. */
export function approvalModeForOrigin(mode: ApprovalMode, origin: { peerInitiated: boolean }): ApprovalMode {
  if (mode === "custom" && origin.peerInitiated) return "auto";
  return mode;
}

/** Custom is a provider config of its own and is never compared. */
const LEVEL_RANK: Partial<Record<ApprovalMode, number>> = { ask: 0, edits: 1, auto: 2, full: 3 };

/** A Chief of Staff's level flows down: only a Chief's, only from the conversation it delegates from, only upward, and capped
 * at what the teammate's engine implements. A bot never raises itself this way; null keeps the teammate's own. */
export function delegatedApprovalMode(input: {
  senderIsChief: boolean;
  senderMode: ApprovalMode;
  sameBot: boolean;
  recipientMode: ApprovalMode;
  recipientDriverKind: string | undefined;
}): ApprovalMode | null {
  if (!input.senderIsChief || input.sameBot) return null;
  // A Chief's Custom reads as Approve for me for a teammate, like any peer-started Custom turn.
  const sender = LEVEL_RANK[approvalModeForOrigin(input.senderMode, { peerInitiated: true })] ?? 0;
  const own = LEVEL_RANK[input.recipientMode];
  if (own === undefined) return null;
  for (const mode of ["full", "auto", "edits"] as const) {
    const rank = LEVEL_RANK[mode]!;
    if (rank > sender) continue;
    if (rank <= own) return null;
    if (supportsApprovalMode(input.recipientDriverKind, mode)) return mode;
  }
  return null;
}

// Tools that ask a person: a malformed AskUserQuestion falls back to the permission path, and approving it runs the tool with
// no answer ("The user did not answer the questions."), so no mode may answer one.
const ASKS_A_PERSON = new Set(["askuserquestion", "ask_user"]);

// Approve for me allows these names and nothing wider: a page fetch and every other tool stay a prompt.
const WEB_SEARCH = new Set(["websearch", "web_search", "web-search", "search_web"]);

function bareToolName(tool: string): string {
  return tool.replace(/^mcp__[^_]+__/, "").toLowerCase();
}

/** The decision log's "which rule": `full-access` and `command-allowlist` are user grants, `web-search` Approve for me's own,
 * `native-approval` a card the provider's reviewer left, `explicit-approval-block` a sandbox widening only Full may answer. */
export type AutoVerdictSource =
  | "full-access"
  | "command-allowlist"
  | "tool-allowlist"
  | "web-search"
  | "own-files"
  | "native-approval"
  | "explicit-approval-block"
  | "outbound-guard"
  | "safety-floor"
  | "no-grant";

export interface AutoVerdict {
  /** The chip text when the app answers, so an auto-approved action is never invisible; null when a human decides. */
  approve: string | null;
  source: AutoVerdictSource;
}

export function autoVerdict(
  mode: ApprovalMode,
  tool: string,
  context?: {
    /** The provider asks to widen its sandbox rather than perform one action; only explicit Full may answer. */
    requiresExplicitApproval?: boolean;
    /** Exact bot/provider/folder/command match against the person's saved rules. */
    commandAllowed?: boolean;
    /** Exact provider/server/tool/config match against a person's saved MCP read grant. */
    toolAllowed?: boolean;
    /** The request only reads the bot's own files: its SOUL.md, skills, memory and conversation folders. */
    ownFilesRead?: boolean;
  },
): AutoVerdict {
  if (ASKS_A_PERSON.has(bareToolName(tool))) {
    return { approve: null, source: "no-grant" };
  }
  // Full's promise is literal, sandbox widening included; entering Full is consent-gated by the bot PATCH endpoint.
  if (mode === "full") return { approve: `approved ${tool} (full access)`, source: "full-access" };
  if (context?.requiresExplicitApproval) return { approve: null, source: "explicit-approval-block" };
  if (isOutboundTool(tool)) return { approve: null, source: "outbound-guard" };
  if (context?.commandAllowed) return { approve: `approved ${tool} (saved command)`, source: "command-allowlist" };
  if (context?.toolAllowed) return { approve: `approved ${tool} (always allowed)`, source: "tool-allowlist" };
  if (context?.ownFilesRead) return { approve: `approved ${tool} (its own files)`, source: "own-files" };
  if (mode === "auto" && WEB_SEARCH.has(bareToolName(tool))) {
    return { approve: `approved ${tool} (web search)`, source: "web-search" };
  }
  if (mode === "auto" || mode === "custom") return { approve: null, source: "native-approval" };
  return { approve: null, source: "no-grant" };
}

/** `unavailable` and `allowed-once` have no transcript row, so callers do not ask for one. */
export function undeliveredApprovalTool(
  outcome: Exclude<RequestOutcome, "allowed-once" | "unavailable"> | "failed",
  source: AutoVerdictSource,
): FailedTurnTool | { name: string; ok: false } {
  if (outcome !== "rejected") return failedTurnTool("could not deliver approval to the provider; retry the task after reconnecting.");
  const grant = source === "command-allowlist" ? "the saved command permission"
    : source === "tool-allowlist" ? "the saved tool permission" : "Full access";
  return { name: `The provider rejected this action despite ${grant}.`, ok: false };
}

/** The client translates by key and falls back to this text, which the server keeps sending for older cards. */
export const HELD_NOTE = {
  "approval.held.outbound": "This sends something on your behalf, so it asks you first.",
  "approval.held.native": "The provider requires your approval for this action.",
  "approval.held.sandbox":
    "This loosens the AI's own safety limits, so only Full access can approve it automatically.",
  "approval.held.undeliveredFull": "Full access couldn't deliver this approval.",
  "approval.held.undelivered": "Approve for me couldn't answer this one.",
} as const;

export type HeldNoteKey = keyof typeof HELD_NOTE;

export function approvalHeldNote(context: {
  source?: AutoVerdictSource;
  permission: boolean;
}): HeldNoteKey | undefined {
  if (!context.permission) return undefined;
  if (context.source === "outbound-guard") return "approval.held.outbound";
  if (context.source === "explicit-approval-block") return "approval.held.sandbox";
  if (context.source === "native-approval") return "approval.held.native";
  return undefined;
}

export function approvalHeldReason(context: {
  source?: AutoVerdictSource;
  permission: boolean;
}): string | undefined {
  const key = approvalHeldNote(context);
  return key && HELD_NOTE[key];
}