Files
cline/apps/cli/src/utils/approval.ts
T
Saoud RizwanandSaoud Rizwan 0852992f3b Clarify model-facing message when user rejects a tool call (#12673)
* Clarify model-facing message when user rejects a tool call

* Include the rejected tool's name in denial reasons

* Move user-rejected tool reason into @cline/shared

* Route new user-rejection approval paths through shared reason builder

Since the original PR, several new approval surfaces landed on main with
their own terse denial strings (CLI connectors, ACP permissions, Cline Hub
webview, desktop webview, example VS Code extension). Route all of them
through buildUserRejectedToolReason so the model sees a consistent,
non-error rejection message.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Add buildUserRejectedToolReason to the @cline/shared integration-test stub

The VS Code integration tests run the tsc-built CJS tree and stub the
ESM-only @cline/shared package in test-setup.js; the stub was missing the
new export, so tool-approval-denial.js threw at module load in CI.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Trim scope back to the minimal rejection-copy fix

Restore the connector deniedReason plumbing, ACP permission strings,
desktop webview reason, example extension reason, and hub server fallback
to their main versions. Those surfaces already attribute the denial to a
user and are outside ENG-2329. Keep the Cline Hub webview change since
that path emits its own rejection string the model sees.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Move rejection guidance suffix into agent runtime per review

* Apply review suggestions: neutral fallback reason and -- separator before rejection suffix

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-31 18:39:51 -07:00

173 lines
4.8 KiB
TypeScript

import { createInterface } from "node:readline";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import { truncate } from "./helpers";
import { c, getActiveCliSession, write } from "./output";
const SHOW_TERMINAL_CURSOR = "\x1b[?25h";
// =============================================================================
// Desktop tool approval
// =============================================================================
let cachedDesktopApprovalRequester:
| Promise<
(
request: ToolApprovalRequest,
options?: {
approvalDir?: string;
sessionId?: string;
},
) => Promise<ToolApprovalResult>
>
| undefined;
async function requestDesktopToolApprovalFromCore(
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
if (!cachedDesktopApprovalRequester) {
cachedDesktopApprovalRequester = import("@cline/core")
.then((module) => {
const fn = (
module as {
requestDesktopToolApproval?: (
request: ToolApprovalRequest,
options?: {
approvalDir?: string;
sessionId?: string;
},
) => Promise<ToolApprovalResult>;
}
).requestDesktopToolApproval;
if (typeof fn !== "function") {
throw new Error(
"Installed @cline/core does not expose requestDesktopToolApproval",
);
}
return fn;
})
.catch(() => {
return async () => ({
approved: false,
reason: "Desktop tool approval IPC is not available",
});
});
}
const requester = await cachedDesktopApprovalRequester;
const sessionId = getActiveCliSession()?.manifest.session_id;
const approvalDir = process.env.CLINE_TOOL_APPROVAL_DIR?.trim();
return requester(request, { approvalDir, sessionId });
}
// =============================================================================
// Terminal tool approval
// =============================================================================
async function requestTerminalToolApproval(
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return {
approved: false,
reason: `Tool "${request.toolName}" requires approval in a TTY session`,
};
}
const preview = truncate(JSON.stringify(request.input), 160);
const answer = await new Promise<string>((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(
`\n${c.yellow}Approve ${c.green}"${request.toolName}" ${c.dim}${preview} ${c.reset}[y/N] `,
(value) => {
rl.close();
resolve(value);
},
);
});
const normalized = answer.trim().toLowerCase();
if (normalized === "y" || normalized === "yes") {
return { approved: true };
}
return {
approved: false,
reason: USER_REJECTED_TOOL_REASON,
};
}
// =============================================================================
// Unified approval entry point
// =============================================================================
export async function requestToolApproval(
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
const mode = process.env.CLINE_TOOL_APPROVAL_MODE?.trim().toLowerCase();
if (mode === "desktop") {
return requestDesktopToolApprovalFromCore(request);
}
return requestTerminalToolApproval(request);
}
// =============================================================================
// Interactive question
// =============================================================================
export async function askQuestionInTerminal(
question: string,
options: string[],
): Promise<string> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return options[0] ?? "";
}
return new Promise<string>((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
write(`\n${c.dim}[follow-up]${c.reset} ${question}\n`);
for (const [index, option] of options.entries()) {
write(`${c.dim} ${index + 1}.${c.reset} ${option}\n`);
}
// Terminal renderers can hide the cursor; restore it so readline shows a
// normal blinking insertion point for the follow-up.
write(SHOW_TERMINAL_CURSOR);
write(
`${c.dim}Choose 1-${options.length} or type a custom answer:${c.reset}\n${c.green}>${c.reset} `,
);
rl.question("", (value) => {
rl.close();
const trimmed = value.trim();
const numeric = Number.parseInt(trimmed, 10);
if (
Number.isInteger(numeric) &&
numeric >= 1 &&
numeric <= options.length
) {
resolve(options[numeric - 1] ?? "");
return;
}
if (trimmed.length > 0) {
resolve(trimmed);
return;
}
resolve(options[0] ?? "");
});
});
}
export async function submitAndExitInTerminal(
summary: string,
verified: boolean,
): Promise<string> {
const status = verified ? "verified" : "unverified";
return `Submission recorded (${status}): ${summary}`;
}