Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/agent-gateway/web/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
Expand Down Expand Up @@ -2101,6 +2118,8 @@ export function getDefaultSettings(): AppSettings {
missingWorkspaceProjectPaths: [],
archivedWorkspaceProjectPaths: [],
systemProxy: getDefaultSystemProxyConfig(),
toolApprovalMode: "off",
bashCwdPolicy: "unrestricted",
},
customProviders,
mcp: {
Expand Down
82 changes: 82 additions & 0 deletions crates/agent-gui/src/components/chat/ToolApprovalModal.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div
className="fixed inset-0 z-[80] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-label={t("chat.toolApproval.title")}
>
<div className="absolute inset-0 bg-black/55 backdrop-blur-sm" />

<div className="relative z-10 w-full max-w-lg overflow-hidden rounded-2xl border border-border/70 bg-background shadow-2xl">
<div className="flex items-start gap-3 border-b border-border/60 px-5 py-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-amber-500/25 bg-amber-500/10 text-amber-500">
<ShieldAlert className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground">
{t("chat.toolApproval.title")}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t(kindLabelKey(assessment.kind))}
</div>
</div>
</div>

<div className="space-y-3 px-5 py-4">
<div className="text-xs font-medium text-muted-foreground">{toolCall.name}</div>
{assessment.detail ? (
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap break-all rounded-lg border border-border/60 bg-muted/30 px-3 py-2 font-mono text-xs text-foreground">
{assessment.detail}
</div>
) : null}
<div className="text-xs text-muted-foreground">{t("chat.toolApproval.hint")}</div>
</div>

<div className="flex justify-end gap-2 border-t border-border/60 px-5 py-3.5">
<Button type="button" variant="outline" size="sm" onClick={() => onDecision(false)}>
{t("chat.toolApproval.deny")}
</Button>
<Button type="button" size="sm" onClick={() => onDecision(true)}>
{t("chat.toolApproval.allow")}
</Button>
</div>
</div>
</div>,
document.body,
);
}
2 changes: 2 additions & 0 deletions crates/agent-gui/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions crates/agent-gui/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,25 @@ export const translations: Record<Locale, Record<string, string>> = {
"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": "关闭窗口后应用继续在后台运行,可从托盘恢复。",
Expand Down Expand Up @@ -3069,6 +3088,26 @@ export const translations: Record<Locale, Record<string, string>> = {
"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":
Expand Down
55 changes: 54 additions & 1 deletion crates/agent-gui/src/lib/chat/runner/agentRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AbortSignal | undefined>): {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
Loading
Loading