From f537e0343b4132a4ebf42682590f16ae99f92214 Mon Sep 17 00:00:00 2001 From: Bsyy95 <300071466+Bsyy95@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:16:12 +0800 Subject: [PATCH] feat(security): tool approval gate and workspace-only Bash cwd policy Dangerous tool calls executed the moment the model asked for them: recursive Delete, mutating SSHManager actions, and Bash/ManagedProcess with a cwd outside the workspace all ran with no user consent, while the Gateway lets remote sessions and cron/subagent runs drive the same tools unattended. The file-tool sandbox was also trivially bypassable because Bash cwd always resolved with allowExternal. This adds two opt-in safety layers (defaults preserve current behavior): Tool approval gate (system.toolApprovalMode = "dangerous"): a pure policy module classifies dangerous calls - Delete, mutating SSHManager actions, and shell calls whose cwd conservatively resolves outside the workspace (absolute paths, ~, file://, ".." escapes). The agent runner gates them in beforeToolCall: with an approval callback the run pauses on a desktop confirmation modal (FIFO queue for parallel calls, abort signal settles pending requests as denied); without a callback - remote gateway sessions and delegated subagent runs - the call is denied with an explanatory tool error. Deny/unattended texts teach the model to adjust course instead of retrying. Bash cwd policy (system.bashCwdPolicy = "workspace-only"): Bash and ManagedProcess cwd resolution drops allowExternal so out-of-workspace working directories fail at resolve time with a hint that the restriction comes from settings. Settings UI gains a security card (approval toggle + cwd scope picker) with zh/en strings; the WebUI settings normalizer mirrors the new fields so browser-side saves keep them intact. Co-authored-by: Cursor --- .../web/src/lib/settings/index.ts | 19 +++ .../src/components/chat/ToolApprovalModal.tsx | 82 ++++++++++ crates/agent-gui/src/components/icons.tsx | 2 + crates/agent-gui/src/i18n/config.ts | 39 +++++ .../src/lib/chat/runner/agentRunner.ts | 55 ++++++- .../src/lib/chat/runner/toolApprovalPolicy.ts | 144 ++++++++++++++++++ crates/agent-gui/src/lib/settings/index.ts | 19 +++ .../agent-gui/src/lib/subagents/agentTool.ts | 5 + crates/agent-gui/src/lib/subagents/run.ts | 6 + .../src/lib/tools/builtinRegistry.ts | 4 + crates/agent-gui/src/lib/tools/shellTools.ts | 16 +- crates/agent-gui/src/pages/ChatPage.tsx | 55 +++++++ .../chat/turns/runAgentConversationTurn.ts | 14 ++ .../src/pages/settings/SystemSettingsForm.tsx | 59 +++++++ .../test/settings/normalization.test.mjs | 20 +++ .../agent-gui/test/tools/shell-tools.test.mjs | 88 +++++++++++ .../test/tools/tool-approval-policy.test.mjs | 117 ++++++++++++++ 17 files changed, 740 insertions(+), 4 deletions(-) create mode 100644 crates/agent-gui/src/components/chat/ToolApprovalModal.tsx create mode 100644 crates/agent-gui/src/lib/chat/runner/toolApprovalPolicy.ts create mode 100644 crates/agent-gui/test/tools/tool-approval-policy.test.mjs diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index d68a9f780..2c4c8133c 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -143,6 +143,11 @@ export type SystemProxyConfig = { passwordConfigured?: boolean; }; +/** off:维持现状;dangerous:危险工具调用需用户确认(无人可确认的会话直接拒绝)。 */ +export type ToolApprovalMode = "off" | "dangerous"; +/** unrestricted:Bash/ManagedProcess 的 cwd 可指向工作区外;workspace-only:锁定在工作区与已启用 Skills 内。 */ +export type BashCwdPolicy = "unrestricted" | "workspace-only"; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -155,6 +160,8 @@ export type SystemSettings = { // in the merged list but render disabled and can never be active. archivedWorkspaceProjectPaths: string[]; systemProxy: SystemProxyConfig; + toolApprovalMode: ToolApprovalMode; + bashCwdPolicy: BashCwdPolicy; }; export type WorkspaceProjectKind = "managed" | "folder" | "history"; @@ -1584,9 +1591,19 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { obj.archivedWorkspaceProjectPaths, ), systemProxy: normalizeSystemProxyConfig(obj.systemProxy), + toolApprovalMode: normalizeToolApprovalMode(obj.toolApprovalMode), + bashCwdPolicy: normalizeBashCwdPolicy(obj.bashCwdPolicy), }; } +export function normalizeToolApprovalMode(input: unknown): ToolApprovalMode { + return input === "dangerous" ? "dangerous" : "off"; +} + +export function normalizeBashCwdPolicy(input: unknown): BashCwdPolicy { + return input === "workspace-only" ? "workspace-only" : "unrestricted"; +} + export function normalizeMcpServerConfig(input: unknown): McpServerConfig { const obj = (input && typeof input === "object" ? input : {}) as Record; const id = typeof obj.id === "string" ? obj.id.trim() : ""; @@ -2101,6 +2118,8 @@ export function getDefaultSettings(): AppSettings { missingWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [], systemProxy: getDefaultSystemProxyConfig(), + toolApprovalMode: "off", + bashCwdPolicy: "unrestricted", }, customProviders, mcp: { diff --git a/crates/agent-gui/src/components/chat/ToolApprovalModal.tsx b/crates/agent-gui/src/components/chat/ToolApprovalModal.tsx new file mode 100644 index 000000000..77b3c87c1 --- /dev/null +++ b/crates/agent-gui/src/components/chat/ToolApprovalModal.tsx @@ -0,0 +1,82 @@ +import { createPortal } from "react-dom"; +import { useLocale } from "../../i18n"; +import type { + DangerousToolAssessment, + ToolApprovalRequest, +} from "../../lib/chat/runner/toolApprovalPolicy"; +import { ShieldAlert } from "../icons"; +import { Button } from "../ui/button"; + +type ToolApprovalModalProps = { + request: ToolApprovalRequest; + onDecision: (approved: boolean) => void; +}; + +function kindLabelKey(kind: DangerousToolAssessment["kind"]) { + switch (kind) { + case "delete": + return "chat.toolApproval.kindDelete"; + case "ssh-mutation": + return "chat.toolApproval.kindSsh"; + case "external-cwd": + return "chat.toolApproval.kindExternalCwd"; + default: + return "chat.toolApproval.kindGeneric"; + } +} + +/** + * 危险工具调用的模态确认卡片:模型的运行会在 beforeToolCall 处等待, + * 直到用户允许 / 拒绝,或运行被取消(signal 撤下卡片并按拒绝处理)。 + */ +export function ToolApprovalModal({ request, onDecision }: ToolApprovalModalProps) { + const { t } = useLocale(); + const { toolCall, assessment } = request; + + return createPortal( +
+
+ +
+
+
+ +
+
+
+ {t("chat.toolApproval.title")} +
+
+ {t(kindLabelKey(assessment.kind))} +
+
+
+ +
+
{toolCall.name}
+ {assessment.detail ? ( +
+ {assessment.detail} +
+ ) : null} +
{t("chat.toolApproval.hint")}
+
+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/crates/agent-gui/src/components/icons.tsx b/crates/agent-gui/src/components/icons.tsx index 0b4b0b514..a3dcab943 100644 --- a/crates/agent-gui/src/components/icons.tsx +++ b/crates/agent-gui/src/components/icons.tsx @@ -103,6 +103,7 @@ import SettingsSource from "~icons/lucide/settings"; import Settings2Source from "~icons/lucide/settings-2"; import Share2Source from "~icons/lucide/share-2"; import ShieldSource from "~icons/lucide/shield"; +import ShieldAlertSource from "~icons/lucide/shield-alert"; import SparkleSource from "~icons/lucide/sparkle"; import SparklesSource from "~icons/lucide/sparkles"; import SquareSource from "~icons/lucide/square"; @@ -554,6 +555,7 @@ export const Settings = createIcon(SettingsSource); export const Settings2 = createIcon(Settings2Source); export const Share2 = createIcon(Share2Source); export const Shield = createIcon(ShieldSource); +export const ShieldAlert = createIcon(ShieldAlertSource); export const SkillIcon = createIcon(SkillIconSource); export const Sparkle = createIcon(SparkleSource); export const Sparkles = createIcon(SparklesSource); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index b91604e42..41e096f50 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1098,6 +1098,25 @@ export const translations: Record> = { "settings.providerUseSystemProxy": "使用应用代理", "settings.providerUseSystemProxyDesc": "该供应商的模型请求经应用代理出网;应用代理未启用时保持直连。", + "chat.toolApproval.title": "确认执行危险操作", + "chat.toolApproval.kindDelete": "AI 请求删除文件 / 目录(递归且不可撤销)", + "chat.toolApproval.kindSsh": "AI 请求在 SSH 远端执行变更操作", + "chat.toolApproval.kindExternalCwd": "AI 请求在工作区之外运行命令", + "chat.toolApproval.kindGeneric": "AI 请求执行危险操作", + "chat.toolApproval.hint": + "拒绝后模型会收到说明并调整方案;允许则立即执行。会话停止时按拒绝处理。", + "chat.toolApproval.allow": "允许执行", + "chat.toolApproval.deny": "拒绝", + "settings.toolApproval": "危险操作确认", + "settings.toolApprovalDesc": + "删除文件、SSH 变更、工作区外命令等危险工具调用需要你在桌面端确认;远程会话与子代理中的危险调用会被直接拒绝。", + "settings.toolApprovalOff": "关闭(默认)", + "settings.toolApprovalDangerous": "危险操作需确认", + "settings.bashCwdPolicy": "Bash 工作目录范围", + "settings.bashCwdPolicyDesc": + "限制 Bash / 后台进程的工作目录只能位于工作区与已启用的 Skills 内,阻止命令在任意目录运行。", + "settings.bashCwdUnrestricted": "不限制(默认)", + "settings.bashCwdWorkspaceOnly": "仅限工作区", "settings.closeWindowBehavior": "关闭窗口", "settings.closeWindowMinimize": "最小化到托盘", "settings.closeWindowMinimizeDesc": "关闭窗口后应用继续在后台运行,可从托盘恢复。", @@ -3069,6 +3088,26 @@ export const translations: Record> = { "settings.providerUseSystemProxy": "Use app proxy", "settings.providerUseSystemProxyDesc": "Route this provider's model requests through the app proxy. Falls back to a direct connection while the app proxy is disabled.", + "chat.toolApproval.title": "Confirm dangerous operation", + "chat.toolApproval.kindDelete": + "The AI wants to delete a file/directory (recursive, irreversible)", + "chat.toolApproval.kindSsh": "The AI wants to run a mutating action on an SSH remote", + "chat.toolApproval.kindExternalCwd": "The AI wants to run a command outside the workspace", + "chat.toolApproval.kindGeneric": "The AI wants to run a dangerous operation", + "chat.toolApproval.hint": + "Denying tells the model to adjust its approach; allowing runs it immediately. Cancelled runs count as denied.", + "chat.toolApproval.allow": "Allow", + "chat.toolApproval.deny": "Deny", + "settings.toolApproval": "Dangerous-operation confirmation", + "settings.toolApprovalDesc": + "Deletes, SSH mutations, and commands outside the workspace require your confirmation on the desktop; remote sessions and subagents get such calls denied automatically.", + "settings.toolApprovalOff": "Off (default)", + "settings.toolApprovalDangerous": "Confirm dangerous operations", + "settings.bashCwdPolicy": "Bash working-directory scope", + "settings.bashCwdPolicyDesc": + "Restrict Bash / managed processes to run only inside the workspace and enabled Skills.", + "settings.bashCwdUnrestricted": "Unrestricted (default)", + "settings.bashCwdWorkspaceOnly": "Workspace only", "settings.closeWindowBehavior": "Close Window", "settings.closeWindowMinimize": "Minimize to tray", "settings.closeWindowMinimizeDesc": diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index 65c85a6ae..dd93fcc36 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -63,6 +63,14 @@ import { } from "../search/providerNativeSearchStatus"; import { comparableToolCall } from "./flattenedToolCallText"; import { recoverAssistantSeedToolCalls } from "./seedToolCalls"; +import { + assessDangerousToolCall, + buildApprovalDeniedText, + buildUnattendedDenialText, + type DangerousToolAssessment, + type RequestToolApproval, + type ToolApprovalPolicy, +} from "./toolApprovalPolicy"; import { wrapStreamWithToolCallArgumentGuard } from "./toolCallArgumentGuard"; function createLinkedAbortSignal(signals: Array): { @@ -691,6 +699,10 @@ export async function runAssistantWithTools(params: { debugLogger?: StreamDebugLogger; subagentScheduler?: SubagentScheduler; allowEmptyWorkdir?: boolean; + /** 危险工具审批策略;缺省表示不启用审批门。 */ + toolApprovalPolicy?: ToolApprovalPolicy; + /** 请求用户确认的回调;策略开启但无回调(远程/子代理)时危险调用直接拒绝。 */ + requestToolApproval?: RequestToolApproval; }) { const modelId = params.model.trim(); if (!modelId) throw new Error("No model selected"); @@ -1263,6 +1275,40 @@ export async function runAssistantWithTools(params: { }); }; + // 危险工具审批门:有回调则等待用户决定(响应取消信号),无回调(远程会话、 + // 子代理运行)则直接拒绝——"有人能确认就问人,没人能确认就不执行"。 + const gateDangerousToolCall = async ( + toolCall: ToolCall, + assessment: DangerousToolAssessment, + hookSignal?: AbortSignal, + ): Promise<{ block: true; reason: string } | undefined> => { + const requestApproval = params.requestToolApproval; + if (!requestApproval) { + return { block: true, reason: buildUnattendedDenialText(toolCall, assessment) }; + } + const linked = createLinkedAbortSignal([hookSignal, params.signal]); + try { + params.onToolStatus?.(`等待用户确认:${summarizeToolCall(toolCall)}`); + const decision = await requestApproval({ + toolCall, + assessment, + signal: linked.signal, + }); + if (linked.signal?.aborted) { + return { block: true, reason: "Cancelled before the tool call was approved." }; + } + if (!decision.approved) { + return { block: true, reason: buildApprovalDeniedText(toolCall, assessment) }; + } + return undefined; + } catch { + return { block: true, reason: buildApprovalDeniedText(toolCall, assessment) }; + } finally { + linked.cleanup(); + params.onToolStatus?.(null); + } + }; + // A truncated call whose repaired arguments also fail schema validation // never reaches beforeToolCall (pi-agent-core validates first), so the // model would see a schema error blaming its own call. Rewrite such tool @@ -1304,7 +1350,7 @@ export async function runAssistantWithTools(params: { afterToolCall: async ({ toolCall }) => ({ isError: toolResultErrorFlags.get(toolCall.id) ?? false, }), - beforeToolCall: async ({ assistantMessage, toolCall }) => { + beforeToolCall: async ({ assistantMessage, toolCall }, hookSignal) => { const effectiveToolCall = normalizeToolCallNameForExecution(toolCall); const effectiveAssistantMessage = normalizeAssistantToolCallNamesForExecution(assistantMessage); @@ -1318,6 +1364,13 @@ export async function runAssistantWithTools(params: { reason: buildTruncatedToolCallText(effectiveToolCall.name, truncationReason), }; } + if (params.toolApprovalPolicy) { + const assessment = assessDangerousToolCall(params.toolApprovalPolicy, effectiveToolCall); + if (assessment) { + const blocked = await gateDangerousToolCall(effectiveToolCall, assessment, hookSignal); + if (blocked) return blocked; + } + } if (effectiveToolCall.name !== "Agent") { return undefined; } diff --git a/crates/agent-gui/src/lib/chat/runner/toolApprovalPolicy.ts b/crates/agent-gui/src/lib/chat/runner/toolApprovalPolicy.ts new file mode 100644 index 000000000..67f981ebe --- /dev/null +++ b/crates/agent-gui/src/lib/chat/runner/toolApprovalPolicy.ts @@ -0,0 +1,144 @@ +import type { ToolCall } from "@earendil-works/pi-ai"; +import type { ToolApprovalMode } from "../../settings"; + +export type ToolApprovalPolicy = { + mode: ToolApprovalMode; + workdir: string; +}; + +export type DangerousToolKind = "delete" | "ssh-mutation" | "external-cwd"; + +export type DangerousToolAssessment = { + kind: DangerousToolKind; + /** 展示给用户的关键参数摘要(路径 / 命令等),已截断。 */ + detail: string; +}; + +export type ToolApprovalRequest = { + toolCall: ToolCall; + assessment: DangerousToolAssessment; + /** 运行被取消时中止等待;UI 应据此撤下确认卡片并按拒绝处理。 */ + signal?: AbortSignal; +}; + +export type ToolApprovalDecision = { + approved: boolean; +}; + +export type RequestToolApproval = (request: ToolApprovalRequest) => Promise; + +const DETAIL_MAX_CHARS = 200; + +// SSHManager 中会改变远端(或本地磁盘)状态的 action;只读查询不需要审批。 +const DANGEROUS_SSH_ACTIONS = new Set([ + "exec", + "send_input", + "sftp_mkdir", + "sftp_delete", + "sftp_rename", + "sftp_upload", + "sftp_download", + "sftp_write_text", +]); + +function truncateDetail(input: string): string { + const text = input.trim(); + if (text.length <= DETAIL_MAX_CHARS) return text; + return `${text.slice(0, DETAIL_MAX_CHARS)}…`; +} + +function argsOf(toolCall: ToolCall): Record { + return toolCall.arguments && typeof toolCall.arguments === "object" + ? (toolCall.arguments as Record) + : {}; +} + +function normalizePathForComparison(input: string): string { + return input.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); +} + +/** + * 同步的保守判定:Bash/ManagedProcess 的 cwd 是否可能落在工作区外。 + * 真正的解析在工具执行期由 ToolPathResolver 完成;这里只需要一个无 IO 的 + * 近似来决定"是否需要用户确认",宁可多问一次也不漏过逃逸路径。 + */ +export function isLikelyExternalCwd(cwd: unknown, workdir: string): boolean { + if (typeof cwd !== "string") return false; + const trimmed = cwd.trim(); + if (!trimmed) return false; + // Skill 路径有独立的访问策略约束,不按外部处理。 + if (trimmed.startsWith("skill://")) return false; + + const normalized = normalizePathForComparison(trimmed); + if (normalized.startsWith("~") || normalized.startsWith("file://")) return true; + + const isAbsolute = normalized.startsWith("/") || /^[a-z]:\//.test(normalized); + if (isAbsolute) { + const workdirNormalized = normalizePathForComparison(workdir); + if (!workdirNormalized) return true; + return !(normalized === workdirNormalized || normalized.startsWith(`${workdirNormalized}/`)); + } + + // 相对路径由解析器锚定在工作区内,但 ".." 可以向上逃逸——保守地要求确认。 + return normalized.split("/").some((segment) => segment === ".."); +} + +/** + * 判定一次工具调用是否属于需要用户确认的危险操作。 + * 返回 null 表示无需确认(策略关闭或调用不危险)。 + */ +export function assessDangerousToolCall( + policy: ToolApprovalPolicy, + toolCall: ToolCall, +): DangerousToolAssessment | null { + if (policy.mode !== "dangerous") return null; + const args = argsOf(toolCall); + + if (toolCall.name === "Delete") { + return { + kind: "delete", + detail: truncateDetail(typeof args.path === "string" ? args.path : ""), + }; + } + + if (toolCall.name === "SSHManager") { + const action = typeof args.action === "string" ? args.action : ""; + if (!DANGEROUS_SSH_ACTIONS.has(action)) return null; + const command = typeof args.command === "string" ? args.command : ""; + const input = typeof args.input === "string" ? args.input : ""; + const remotePath = typeof args.remote_path === "string" ? args.remote_path : ""; + const extra = command || input || remotePath; + return { + kind: "ssh-mutation", + detail: truncateDetail(extra ? `${action}: ${extra}` : action), + }; + } + + if (toolCall.name === "Bash" || toolCall.name === "ManagedProcess") { + if (!isLikelyExternalCwd(args.cwd, policy.workdir)) return null; + const command = typeof args.command === "string" ? args.command : ""; + return { + kind: "external-cwd", + detail: truncateDetail(`cwd=${String(args.cwd)}${command ? ` · ${command}` : ""}`), + }; + } + + return null; +} + +/** 拒绝后的教学文案:让模型改方案或询问用户,而不是原样重试。 */ +export function buildApprovalDeniedText(toolCall: ToolCall, assessment: DangerousToolAssessment) { + return ( + `The user declined this ${toolCall.name} call (${assessment.kind}: ${assessment.detail}). ` + + "Do not retry the same call. Adjust your approach, or ask the user how they want to proceed." + ); +} + +/** 无人值守会话(远程 / 子代理)里直接拒绝危险调用的说明文案。 */ +export function buildUnattendedDenialText(toolCall: ToolCall, assessment: DangerousToolAssessment) { + return ( + `${toolCall.name} was blocked by the tool approval policy (${assessment.kind}: ${assessment.detail}). ` + + "This session has no one available to approve dangerous tool calls (remote or delegated run). " + + "Continue without this operation, or ask the user to run it from the desktop chat / relax the approval setting." + ); +} diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 8b2ddf8ea..0f8c8d758 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -155,6 +155,11 @@ export type SystemProxyConfig = { passwordConfigured?: boolean; }; +/** off:维持现状;dangerous:危险工具调用需用户确认(无人可确认的会话直接拒绝)。 */ +export type ToolApprovalMode = "off" | "dangerous"; +/** unrestricted:Bash/ManagedProcess 的 cwd 可指向工作区外;workspace-only:锁定在工作区与已启用 Skills 内。 */ +export type BashCwdPolicy = "unrestricted" | "workspace-only"; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -167,6 +172,8 @@ export type SystemSettings = { // in the merged list but render disabled and can never be active. archivedWorkspaceProjectPaths: string[]; systemProxy: SystemProxyConfig; + toolApprovalMode: ToolApprovalMode; + bashCwdPolicy: BashCwdPolicy; }; export type WorkspaceProjectKind = "managed" | "folder" | "history"; @@ -1449,9 +1456,19 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { obj.archivedWorkspaceProjectPaths, ), systemProxy: normalizeSystemProxyConfig(obj.systemProxy), + toolApprovalMode: normalizeToolApprovalMode(obj.toolApprovalMode), + bashCwdPolicy: normalizeBashCwdPolicy(obj.bashCwdPolicy), }; } +export function normalizeToolApprovalMode(input: unknown): ToolApprovalMode { + return input === "dangerous" ? "dangerous" : "off"; +} + +export function normalizeBashCwdPolicy(input: unknown): BashCwdPolicy { + return input === "workspace-only" ? "workspace-only" : "unrestricted"; +} + export function normalizeMcpServerConfig(input: unknown): McpServerConfig { const obj = (input && typeof input === "object" ? input : {}) as Record; const id = typeof obj.id === "string" ? obj.id.trim() : ""; @@ -1977,6 +1994,8 @@ export function getDefaultSettings(): AppSettings { missingWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [], systemProxy: getDefaultSystemProxyConfig(), + toolApprovalMode: "off", + bashCwdPolicy: "unrestricted", }, customProviders, mcp: { diff --git a/crates/agent-gui/src/lib/subagents/agentTool.ts b/crates/agent-gui/src/lib/subagents/agentTool.ts index a34533cf5..3a55386c5 100644 --- a/crates/agent-gui/src/lib/subagents/agentTool.ts +++ b/crates/agent-gui/src/lib/subagents/agentTool.ts @@ -1,6 +1,7 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; import { Type } from "typebox"; +import type { ToolApprovalPolicy } from "../chat/runner/toolApprovalPolicy"; import type { RuntimePlatform } from "../runtimePlatform"; import type { ProviderId } from "../settings"; import { @@ -189,6 +190,8 @@ export type SubagentRuntimeConfig = { templates: SubagentTemplate[]; store: SubagentConversationStore; scheduler: SubagentScheduler; + /** 子代理运行无人值守:策略开启时危险调用直接拒绝。 */ + toolApprovalPolicy?: ToolApprovalPolicy; }; export function createSubagentTools(params: { @@ -202,6 +205,7 @@ export function createSubagentTools(params: { templates: SubagentTemplate[]; store: SubagentConversationStore; scheduler: SubagentScheduler; + toolApprovalPolicy?: ToolApprovalPolicy; baseTools: Tool[]; executeToolCall: (toolCall: ToolCall, signal?: AbortSignal) => Promise; metadataByName: Map; @@ -313,6 +317,7 @@ export function createSubagentTools(params: { workdir: params.workdir, sessionId: params.sessionId, messageBusEnabled, + toolApprovalPolicy: params.toolApprovalPolicy, store, scheduler, worktree: worktreeIpc, diff --git a/crates/agent-gui/src/lib/subagents/run.ts b/crates/agent-gui/src/lib/subagents/run.ts index a47219f9c..2ea48e5b4 100644 --- a/crates/agent-gui/src/lib/subagents/run.ts +++ b/crates/agent-gui/src/lib/subagents/run.ts @@ -9,6 +9,7 @@ import { } from "../chat/conversation/conversationState"; import { createTurnCancellationFromSignal } from "../chat/conversation/turnCancellation"; import { runAssistantWithTools } from "../chat/runner/agentRunner"; +import type { ToolApprovalPolicy } from "../chat/runner/toolApprovalPolicy"; import type { RuntimePlatform } from "../runtimePlatform"; import type { CodexRequestFormat, @@ -71,6 +72,8 @@ export type SubagentRunEnvironment = { workdir: string; sessionId?: string; messageBusEnabled: boolean; + /** 子代理运行无人值守:策略开启时危险调用直接拒绝(不弹确认)。 */ + toolApprovalPolicy?: ToolApprovalPolicy; store: SubagentConversationStore; scheduler: SubagentScheduler; worktree: SubagentWorktreeIpc; @@ -589,6 +592,9 @@ export async function executeSubagentRun( nativeWebSearch: env.runtime.nativeWebSearchEnabled !== false, tools: childTools, subagentScheduler: env.scheduler, + toolApprovalPolicy: env.toolApprovalPolicy + ? { ...env.toolApprovalPolicy, workdir: childWorkdir } + : undefined, executeToolCall: (childToolCall, childSignal) => { if (!childToolNames.has(childToolCall.name)) { return Promise.resolve( diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 411834ca0..7761376fc 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -127,6 +127,8 @@ type BuildBuiltinBaseToolRegistryParams = { fileState: FileToolState; skillsEnabled: boolean; skillsRootDir?: string; + /** false 时 Bash/ManagedProcess 的 cwd 锁定在工作区与已启用 Skills 内。 */ + bashExternalCwdAllowed?: boolean; skillAccessPolicy?: SkillAccessPolicy; onManagedSkillsChanged?: (change: { action: "install" | "create"; @@ -176,6 +178,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP skillsRootDir: params.skillsRootDir, skillAccessPolicy: params.skillAccessPolicy, managedProcessEnabled: params.runtimeScope === "chat", + externalCwdAllowed: params.bashExternalCwdAllowed, resolveHomeDir, }), ...(params.skillsEnabled @@ -298,6 +301,7 @@ export async function buildBuiltinToolRegistry( templates: subagentRuntime.templates, store: subagentRuntime.store, scheduler: subagentRuntime.scheduler, + toolApprovalPolicy: subagentRuntime.toolApprovalPolicy, baseTools: baseRegistry.tools, executeToolCall: baseRegistry.executeToolCall, metadataByName: baseRegistry.metadataByName, diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 81c441e4e..fb5ee857b 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -371,6 +371,8 @@ export function createShellTools(params: { skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; managedProcessEnabled?: boolean; + /** false 时 Bash/ManagedProcess 的 cwd 锁定在工作区与已启用 Skills 内。 */ + externalCwdAllowed?: boolean; resolveHomeDir?: () => Promise; }): BuiltinToolBundle { const timeoutPolicy = resolveBashTimeoutPolicy(params.providerId); @@ -390,6 +392,14 @@ export function createShellTools(params: { const workdir = params.workdir; const allowSkillsRoot = params.skillsRootEnabled === true; const allowManagedProcess = params.managedProcessEnabled !== false; + const externalCwdAllowed = params.externalCwdAllowed !== false; + + // workspace-only 模式下的越界报错补上设置来源,避免模型误以为是路径拼写问题。 + function appendCwdPolicyHint(message: string) { + if (externalCwdAllowed) return message; + if (!/outside the workspace/i.test(message)) return message; + return `${message} Bash cwd is locked to the workspace by settings (Bash working directory: workspace only); run the command inside the workspace instead.`; + } const skillAccessPolicy = params.skillAccessPolicy; let cachedSkillsRootDir = typeof params.skillsRootDir === "string" ? params.skillsRootDir.trim() : ""; @@ -781,7 +791,7 @@ export function createShellTools(params: { label: "ManagedProcess.cwd", intent: "cwd", required: false, - allowExternal: true, + allowExternal: externalCwdAllowed, }); const cwd = backendCwd(cwdResolved); const label = @@ -961,7 +971,7 @@ export function createShellTools(params: { label: "Bash.cwd", intent: "cwd", required: false, - allowExternal: true, + allowExternal: externalCwdAllowed, }); cwd = backendCwd(cwdResolved); } catch (err) { @@ -972,7 +982,7 @@ export function createShellTools(params: { content: [ { type: "text", - text: asErrorMessage(err), + text: appendCwdPolicyHint(asErrorMessage(err)), }, ], details: {}, diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index cbc7840eb..9c9880376 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -24,6 +24,7 @@ import type { } from "../components/chat/MentionComposer"; import { type NotifyItem, NotifyToast } from "../components/chat/NotifyToast"; import { SharedHistoryManagerModal } from "../components/chat/SharedHistoryManagerModal"; +import { ToolApprovalModal } from "../components/chat/ToolApprovalModal"; import { Ban, PanelRightClose, PanelRightOpen, Terminal, Upload } from "../components/icons"; import { MacOsTitleBarSpacer, MacOsTitleBarToggle } from "../components/MacOsTitleBarSpacer"; import type { @@ -97,6 +98,11 @@ import { getFirstUserMessageText, isAbortLikeError, } from "../lib/chat/page/chatPageHelpers"; +import type { + RequestToolApproval, + ToolApprovalDecision, + ToolApprovalRequest, +} from "../lib/chat/runner/toolApprovalPolicy"; import type { ScrollFollowHandle } from "../lib/chat-scroll/useScrollFollow"; import { createStreamDebugLogger } from "../lib/debug/agentDebug"; import { tauriGitClient } from "../lib/git/tauriGitClient"; @@ -547,6 +553,12 @@ function resolveConversationTitleModelSelection( }; } +type PendingToolApproval = { + request: ToolApprovalRequest; + resolve: (decision: ToolApprovalDecision) => void; + settled: boolean; +}; + function buildProviderRuntimeConfig( provider: AppSettings["customProviders"][number], model: string, @@ -814,6 +826,35 @@ export function ChatPage(props: ChatPageProps) { // render needs (draft detection, pending-item effect, workspace root). const historyItems = useSidebarSelector(sidebarStore, selectConversations); const sidebarConversationsById = useSidebarSelector(sidebarStore, (s) => s.byId); + // 危险工具审批:runner 在 beforeToolCall 处等待队首请求被允许 / 拒绝。 + // 并行工具可能同时请求多个确认,按 FIFO 逐个弹出。 + const toolApprovalQueueRef = useRef([]); + const [activeToolApproval, setActiveToolApproval] = useState(null); + const settleToolApproval = useCallback((entry: PendingToolApproval, approved: boolean) => { + if (entry.settled) return; + entry.settled = true; + toolApprovalQueueRef.current = toolApprovalQueueRef.current.filter((item) => item !== entry); + entry.resolve({ approved }); + setActiveToolApproval(toolApprovalQueueRef.current[0] ?? null); + }, []); + const requestToolApproval = useCallback( + (request) => + new Promise((resolve) => { + const entry: PendingToolApproval = { request, resolve, settled: false }; + if (request.signal?.aborted) { + resolve({ approved: false }); + return; + } + toolApprovalQueueRef.current = [...toolApprovalQueueRef.current, entry]; + setActiveToolApproval((prev) => prev ?? entry); + // 运行取消时撤下卡片并按拒绝处理,避免确认框悬挂在已结束的回合上。 + request.signal?.addEventListener("abort", () => settleToolApproval(entry, false), { + once: true, + }); + }), + [settleToolApproval], + ); + const [shareConversation, setShareConversation] = useState(null); const [shareStatus, setShareStatus] = useState(null); const [shareLoading, setShareLoading] = useState(false); @@ -4546,6 +4587,13 @@ export function ChatPage(props: ChatPageProps) { associatedSshHostIds: effectiveAssociatedSshHostIds, sshManagerRemoteAllowed: !gatewayBridgeRequest || settings.remote.enableWebSshTerminal === true, + bashExternalCwdAllowed: settings.system.bashCwdPolicy !== "workspace-only", + toolApprovalPolicy: + settings.system.toolApprovalMode === "dangerous" + ? { mode: "dangerous", workdir: effectiveWorkdir } + : undefined, + // 远程会话无人在桌面前确认:不提供回调,危险调用由 runner 直接拒绝。 + requestToolApproval: gatewayBridgeRequest ? undefined : requestToolApproval, onSshSessionsChanged: (change) => { if (change.action === "create") { ensureSshTunnelToolTab(change.projectPathKey); @@ -5422,6 +5470,13 @@ export function ChatPage(props: ChatPageProps) { }} /> + {activeToolApproval ? ( + settleToolApproval(activeToolApproval, approved)} + /> + ) : null} + {shareConversation ? ( void; + bashExternalCwdAllowed?: boolean; + toolApprovalPolicy?: ToolApprovalPolicy; + requestToolApproval?: RequestToolApproval; sessionId: string; conversationId: string; conversationCwd?: string; @@ -292,6 +299,9 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP associatedSshHostIds, sshManagerRemoteAllowed, onSshSessionsChanged, + bashExternalCwdAllowed, + toolApprovalPolicy, + requestToolApproval, sessionId, conversationId, conversationCwd, @@ -401,6 +411,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP onManagedSkillsChanged, runtimeScope: "chat", currentChatModel: selectedModel, + bashExternalCwdAllowed, selectedSystemToolIds, getMcpSettings, applyMcpOps, @@ -426,6 +437,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP templates: enabledSubagentTemplates(agentTemplates), store: subagentStore, scheduler: subagentScheduler, + toolApprovalPolicy, } : undefined, }); @@ -661,6 +673,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP nativeWebSearch: nativeWebSearchEnabled, tools: combinedTools, subagentScheduler, + toolApprovalPolicy, + requestToolApproval, executeToolCall: combinedExecutor, onTurnStart: (round) => { activeAgentRound = round; diff --git a/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx b/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx index 539098025..78434a03c 100644 --- a/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx +++ b/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx @@ -9,6 +9,7 @@ import { MonitorSmartphone, Moon, ScanText, + ShieldAlert, Sun, Terminal, Wrench, @@ -538,6 +539,64 @@ export function SystemSettingsForm(props: SettingsSectionProps) {
+
+
+
+ + {t("settings.toolApproval")} +
+ + setSettings((prev) => + updateSystem(prev, { + toolApprovalMode: + prev.system.toolApprovalMode === "dangerous" ? "off" : "dangerous", + }), + ) + } + /> +
+

{t("settings.toolApprovalDesc")}

+ +
+
+
{t("settings.bashCwdPolicy")}
+
+ {t("settings.bashCwdPolicyDesc")} +
+
+
+ {( + [ + ["unrestricted", "settings.bashCwdUnrestricted"], + ["workspace-only", "settings.bashCwdWorkspaceOnly"], + ] as const + ).map(([value, labelKey]) => { + const selected = settings.system.bashCwdPolicy === value; + return ( + + ); + })} +
+
+
+
diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index ee7d7dde1..12f7758bc 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -115,6 +115,26 @@ test("claude provider normalization defaults routing, caching, and model limits" assert.equal(provider.models[0].maxOutputToken, 32_000); }); +test("system settings normalize tool approval and bash cwd policy", () => { + const defaults = settings.normalizeSystemSettings({}); + assert.equal(defaults.toolApprovalMode, "off"); + assert.equal(defaults.bashCwdPolicy, "unrestricted"); + + const configured = settings.normalizeSystemSettings({ + toolApprovalMode: "dangerous", + bashCwdPolicy: "workspace-only", + }); + assert.equal(configured.toolApprovalMode, "dangerous"); + assert.equal(configured.bashCwdPolicy, "workspace-only"); + + const invalid = settings.normalizeSystemSettings({ + toolApprovalMode: "always", + bashCwdPolicy: "everywhere", + }); + assert.equal(invalid.toolApprovalMode, "off"); + assert.equal(invalid.bashCwdPolicy, "unrestricted"); +}); + test("gemini provider normalization keeps native routing and model limits", () => { const provider = settings.normalizeCustomProvider({ id: "gemini-1", diff --git a/crates/agent-gui/test/tools/shell-tools.test.mjs b/crates/agent-gui/test/tools/shell-tools.test.mjs index 8cbaa6245..e1571815f 100644 --- a/crates/agent-gui/test/tools/shell-tools.test.mjs +++ b/crates/agent-gui/test/tools/shell-tools.test.mjs @@ -158,6 +158,94 @@ test("Bash tool schema allows larger timeout values but clamps for Codex", async assert.match(result.content[0].text, /timeout_ms: 30000/); }); +test("Bash cwd is locked to the workspace when externalCwdAllowed is false", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + return { + exit_code: 0, + shell: "bash", + stdout: "ready\n", + stderr: "", + stdout_truncated: false, + stderr_truncated: false, + timed_out: false, + cancelled: false, + effective_timeout_ms: args.timeout_ms, + duration_ms: 12, + }; + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "claude_code", + externalCwdAllowed: false, + }); + + const blocked = await bundle.executeToolCall({ + ...createBashCall(), + arguments: { command: "ls", cwd: "/etc", timeout_ms: 1000 }, + }); + assert.equal(blocked.isError, true); + assert.match(blocked.content[0].text, /outside the workspace/); + assert.match(blocked.content[0].text, /locked to the workspace by settings/); + assert.equal(calls.length, 0, "no shell command may run for an external cwd"); + + const allowed = await bundle.executeToolCall({ + ...createBashCall(), + arguments: { command: "ls", cwd: "src", timeout_ms: 1000 }, + }); + assert.equal(allowed.isError, false); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.cwd, "src"); +}); + +test("Bash external cwd stays allowed by default", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + return { + exit_code: 0, + shell: "bash", + stdout: "ready\n", + stderr: "", + stdout_truncated: false, + stderr_truncated: false, + timed_out: false, + cancelled: false, + effective_timeout_ms: args.timeout_ms, + duration_ms: 12, + }; + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "claude_code", + }); + + const result = await bundle.executeToolCall({ + ...createBashCall(), + arguments: { command: "ls", cwd: "/etc", timeout_ms: 1000 }, + }); + assert.equal(result.isError, false); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.cwd, "/etc"); +}); + test("Bash tool rejects unsupported root arguments", async () => { const calls = []; const loader = createTsModuleLoader({ diff --git a/crates/agent-gui/test/tools/tool-approval-policy.test.mjs b/crates/agent-gui/test/tools/tool-approval-policy.test.mjs new file mode 100644 index 000000000..c85ba5468 --- /dev/null +++ b/crates/agent-gui/test/tools/tool-approval-policy.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const policyModule = loader.loadModule("src/lib/chat/runner/toolApprovalPolicy.ts"); +const { + assessDangerousToolCall, + buildApprovalDeniedText, + buildUnattendedDenialText, + isLikelyExternalCwd, +} = policyModule; + +const POLICY = { mode: "dangerous", workdir: "C:\\Users\\me\\repo" }; +const OFF_POLICY = { mode: "off", workdir: "C:\\Users\\me\\repo" }; + +function toolCall(name, args = {}) { + return { type: "toolCall", id: "tc-1", name, arguments: args }; +} + +test("policy off never flags anything", () => { + assert.equal(assessDangerousToolCall(OFF_POLICY, toolCall("Delete", { path: "src" })), null); + assert.equal( + assessDangerousToolCall(OFF_POLICY, toolCall("Bash", { command: "rm -rf /", cwd: "/" })), + null, + ); +}); + +test("Delete always requires approval under the dangerous policy", () => { + const assessment = assessDangerousToolCall(POLICY, toolCall("Delete", { path: "src/app.ts" })); + assert.equal(assessment?.kind, "delete"); + assert.equal(assessment?.detail, "src/app.ts"); +}); + +test("SSHManager mutating actions require approval, read-only ones do not", () => { + const exec = assessDangerousToolCall( + POLICY, + toolCall("SSHManager", { action: "exec", command: "systemctl restart app" }), + ); + assert.equal(exec?.kind, "ssh-mutation"); + assert.match(exec?.detail ?? "", /systemctl restart app/); + + for (const action of ["sftp_delete", "sftp_upload", "sftp_write_text", "send_input"]) { + assert.equal( + assessDangerousToolCall(POLICY, toolCall("SSHManager", { action }))?.kind, + "ssh-mutation", + `${action} should require approval`, + ); + } + + for (const action of ["list_hosts", "list_sessions", "read_session", "sftp_list", "sftp_stat"]) { + assert.equal( + assessDangerousToolCall(POLICY, toolCall("SSHManager", { action })), + null, + `${action} should not require approval`, + ); + } +}); + +test("Bash with an external cwd requires approval; workspace cwd does not", () => { + const external = assessDangerousToolCall( + POLICY, + toolCall("Bash", { command: "dir", cwd: "C:\\Windows\\System32" }), + ); + assert.equal(external?.kind, "external-cwd"); + assert.match(external?.detail ?? "", /System32/); + + assert.equal( + assessDangerousToolCall(POLICY, toolCall("Bash", { command: "dir", cwd: "src" })), + null, + ); + assert.equal( + assessDangerousToolCall(POLICY, toolCall("Bash", { command: "dir" })), + null, + "no cwd means workspace root", + ); + assert.equal( + assessDangerousToolCall( + POLICY, + toolCall("ManagedProcess", { action: "start", command: "vite", cwd: "/etc" }), + )?.kind, + "external-cwd", + ); +}); + +test("isLikelyExternalCwd covers separators, case, home, parent-escapes, and skills", () => { + const workdir = "C:\\Users\\me\\repo"; + assert.equal(isLikelyExternalCwd("C:/Users/ME/repo/src", workdir), false); + assert.equal(isLikelyExternalCwd("C:\\Users\\me\\repo", workdir), false); + assert.equal(isLikelyExternalCwd("C:\\Users\\me\\repository", workdir), true); + assert.equal(isLikelyExternalCwd("D:/other", workdir), true); + assert.equal(isLikelyExternalCwd("/etc", workdir), true); + assert.equal(isLikelyExternalCwd("~/Downloads", workdir), true); + assert.equal(isLikelyExternalCwd("file:///tmp", workdir), true); + assert.equal(isLikelyExternalCwd("../outside", workdir), true); + assert.equal(isLikelyExternalCwd("src/../lib", workdir), true); + assert.equal(isLikelyExternalCwd("skill://helper/scripts", workdir), false); + assert.equal(isLikelyExternalCwd(undefined, workdir), false); + assert.equal(isLikelyExternalCwd("", workdir), false); +}); + +test("denial texts teach the model instead of inviting retries", () => { + const call = toolCall("Delete", { path: "src" }); + const assessment = { kind: "delete", detail: "src" }; + assert.match(buildApprovalDeniedText(call, assessment), /declined/); + assert.match(buildApprovalDeniedText(call, assessment), /Do not retry/); + assert.match(buildUnattendedDenialText(call, assessment), /approval policy/); + assert.match(buildUnattendedDenialText(call, assessment), /remote or delegated/); +}); + +test("long details are truncated", () => { + const assessment = assessDangerousToolCall( + POLICY, + toolCall("Delete", { path: "x".repeat(500) }), + ); + assert.ok((assessment?.detail.length ?? 0) <= 201); +});