feat(intercept): 未命中规则的命令接入 LLM 兜底审批
在正则/字符串规则之外新增一套独立的模型兜底层:规则命中直接按规则走; 一条规则都没命中时,才由模型做语义判定(ALLOW/ASK/DENY),人工审批兜底。 - intercept: DefaultJudgePrompt(三态判定提示词)+ ParseVerdict;Reviewer 回调注入; Judge() 读全局 llm_judge_* 配置、超时/失败回退、ask 填审批超时、模型判定加 [模型] 前缀 - guard: applyIntercept 未命中分支调 Judge;模型 allow 不落库,deny/ask 带 reason 入库 - db: intercept_pending 加 reason 列(带 ALTER IF NOT EXISTS 补旧库); create 函数支持 reason 与 ruleID 0→NULL;历史查询带出 reason - server: 用 providerForProfile + 单次 Stream 收集注入裁判;GET/PUT /api/intercept/judge - web: 拦截页拆「拦截规则 / 模型配置」两 tab;拦截范围提为共用信息条; 模型配置双栏布局(提示词直接展开编辑);审批/任务拦截页标注「模型判定」+ 理由 配置默认值:失败回退 allow、审批超时动作 deny、模型 allow 不落库、 提示词默认即内置模板(清空回退模板)。无需 DB 结构性迁移(judge 走 settings KV)。 Claude-Session: https://claude.ai/code/session_019QXSCDmzimwnjntBNCgUD7
This commit is contained in:
1 parent
f63a451b87
commit
95d2309935
14 files changed
+860
-66
No files matched your search
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
@@ -38,6 +38,30 @@ function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-xs text-muted-foreground">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
@@ -153,7 +177,7 @@ export function InterceptTab({ taskId }: Props) {
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -199,7 +223,7 @@ export function InterceptTab({ taskId }: Props) {
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { BotIcon, CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
@@ -40,6 +40,33 @@ function SourceCell({ row }: { row: InterceptApprovalRow }) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
// MatchCell shows the matched rule name, or a "模型判定" badge when the decision
|
||||
// came from the LLM fallback judge (rule_id null). The reason (rule message or
|
||||
// model rationale, minus the [模型] prefix) is shown beneath.
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-sm">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
@@ -155,11 +182,7 @@ export default function ApprovalsPage() {
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell>
|
||||
{row.rule_name
|
||||
? <span className="text-sm">{row.rule_name}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -221,11 +244,7 @@ export default function ApprovalsPage() {
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell>
|
||||
{row.rule_name
|
||||
? <span className="text-sm">{row.rule_name}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BotIcon,
|
||||
ListFilterIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
@@ -48,8 +49,9 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptRule, InterceptAction, Tool } from "@/lib/types";
|
||||
import type { InterceptRule, InterceptAction, JudgeConfig, LLMProfile, Tool } from "@/lib/types";
|
||||
|
||||
// ---- tool scope ----
|
||||
|
||||
@@ -143,6 +145,203 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
// ---- LLM fallback judge card ----
|
||||
|
||||
const FOLLOW_ACTIVE = "0"; // profile_id 0 = 跟随激活/默认配置
|
||||
|
||||
const defaultJudge = (): JudgeConfig => ({
|
||||
enabled: false,
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
});
|
||||
|
||||
function JudgeCard() {
|
||||
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [j, ps] = await Promise.all([api.interceptGetJudgeConfig(), api.llmProfiles()]);
|
||||
setCfg(j);
|
||||
setProfiles(ps);
|
||||
} catch (e) {
|
||||
toast.error("加载模型兜底配置失败: " + (e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function patch(p: Partial<JudgeConfig>) {
|
||||
setCfg((c) => ({ ...c, ...p }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.interceptSetJudgeConfig(cfg);
|
||||
toast.success("模型兜底配置已保存");
|
||||
await load(); // 回读:提示词若清空则回填内置模板
|
||||
} catch (e) {
|
||||
toast.error("保存失败: " + (e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePrompt() {
|
||||
// 清空提示词并保存 → 服务端下次返回内置模板全文,回填到输入框。
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.interceptSetJudgeConfig({ ...cfg, prompt: "" });
|
||||
const j = await api.interceptGetJudgeConfig();
|
||||
setCfg(j);
|
||||
toast.success("已恢复内置默认模板");
|
||||
} catch (e) {
|
||||
toast.error("恢复失败: " + (e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 启用开关 —— 独立高亮条 */}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
|
||||
cfg.enabled ? "border-violet-400/50 bg-violet-50/40 dark:bg-violet-950/20" : "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BotIcon className={`h-5 w-5 shrink-0 ${cfg.enabled ? "text-violet-600" : "text-muted-foreground"}`} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">模型兜底审批</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
在<span className="font-medium text-foreground">拦截范围</span>内、且<span className="font-medium text-foreground">没有任何拦截规则命中</span>的命令,才由模型做语义判断(放行 / 转人工 / 拦截)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{cfg.enabled ? "已启用" : "未启用"}</span>
|
||||
<Switch checked={cfg.enabled} disabled={loading} onCheckedChange={(v) => patch({ enabled: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cfg.enabled && (
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* 左:提示词编辑器(直接展开,主区域) */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardContent className="flex h-full flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">审批提示词</p>
|
||||
<p className="text-xs text-muted-foreground">模型据此判定 ALLOW / ASK / DENY,可直接编辑</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={restorePrompt} disabled={saving}>
|
||||
恢复默认模板
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-[22rem] flex-1 resize-none font-mono text-xs leading-relaxed"
|
||||
value={cfg.prompt}
|
||||
onChange={(e) => patch({ prompt: e.target.value })}
|
||||
placeholder="留空使用内置模板"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-right text-[11px] text-muted-foreground">{cfg.prompt.length} 字</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右:判定参数(设置栏) */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">判定模型与策略</p>
|
||||
<Field label="审批模型">
|
||||
<Select value={String(cfg.profile_id || 0)} onValueChange={(v) => patch({ profile_id: Number(v) })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FOLLOW_ACTIVE}>跟随激活配置</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="模型判定超时(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={cfg.timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) patch({ timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="模型失败时(出错 / 超时 / 无法解析)">
|
||||
<Select value={cfg.fail_action} onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">放行</SelectItem>
|
||||
<SelectItem value="ask">转人工审批</SelectItem>
|
||||
<SelectItem value="deny">拦截</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">人工审批(模型判为「转人工」时)</p>
|
||||
<Field label="审批等待超时(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
value={cfg.ask_timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) patch({ ask_timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="超时后默认动作">
|
||||
<Select value={cfg.ask_timeout_action} onValueChange={(v) => patch({ ask_timeout_action: v as JudgeConfig["ask_timeout_action"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">拦截</SelectItem>
|
||||
<SelectItem value="allow">放行</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={save} disabled={saving || loading}>
|
||||
{saving ? "保存中…" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function InterceptPage() {
|
||||
@@ -161,9 +360,19 @@ export default function InterceptPage() {
|
||||
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
|
||||
const [scopeLoading, setScopeLoading] = React.useState(false);
|
||||
const [scopeSaving, setScopeSaving] = React.useState(false);
|
||||
const [scopeTools, setScopeTools] = React.useState<string[]>([]); // 页头信息条:当前进入拦截的工具
|
||||
|
||||
// ---- data ----
|
||||
|
||||
const loadScope = React.useCallback(async () => {
|
||||
try {
|
||||
const cfg = await api.interceptGetToolConfig();
|
||||
setScopeTools(cfg.enabled_tools);
|
||||
} catch {
|
||||
// 信息条非关键,失败静默
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
const r = await api.interceptRules();
|
||||
@@ -175,7 +384,7 @@ export default function InterceptPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => { load(); }, [load]);
|
||||
React.useEffect(() => { load(); loadScope(); }, [load, loadScope]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (form.match_type !== "regex" || !form.pattern) { setRegexErr(""); setRegexWarn(false); return; }
|
||||
@@ -285,6 +494,7 @@ export default function InterceptPage() {
|
||||
try {
|
||||
await api.interceptSetToolConfig([...enabledTools]);
|
||||
toast.success("拦截范围已保存");
|
||||
setScopeTools([...enabledTools]);
|
||||
setScopeOpen(false);
|
||||
} catch (e) {
|
||||
toast.error("保存失败: " + (e as Error).message);
|
||||
@@ -298,34 +508,74 @@ export default function InterceptPage() {
|
||||
// ---- render ----
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-6">
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
|
||||
{/* ---- header ---- */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlertIcon className="h-5 w-5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">拦截规则</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
工具执行前按优先级(数字越大越先)逐条匹配,首条命中的规则生效
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={openScope}>
|
||||
<ListFilterIcon className="h-4 w-4" />
|
||||
拦截范围
|
||||
</Button>
|
||||
<Button onClick={openNew} size="sm">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlertIcon className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">命令拦截</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
工具执行前先按拦截规则匹配;未命中的命令可交由模型兜底判定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- rules table ---- */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* ---- 拦截范围信息条(规则匹配与模型兜底共用:不在范围内的工具两者都不介入)---- */}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-2.5 ${
|
||||
scopeTools.length === 0
|
||||
? "border-amber-400/60 bg-amber-50/50 dark:bg-amber-950/20"
|
||||
: "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ListFilterIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 font-medium">拦截范围</span>
|
||||
{scopeTools.length === 0 ? (
|
||||
<span className="text-amber-700 dark:text-amber-500">
|
||||
未启用任何工具 — 拦截规则与模型兜底均不会生效
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary" className="shrink-0">{scopeTools.length} 个工具</Badge>
|
||||
<span className="truncate text-muted-foreground" title={scopeTools.join("、")}>
|
||||
{scopeTools.join("、")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={scopeTools.length === 0 ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={openScope}
|
||||
>
|
||||
<ListFilterIcon className="h-4 w-4" />
|
||||
调整范围
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="rules" className="flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">拦截规则</TabsTrigger>
|
||||
<TabsTrigger value="judge">模型配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ---- tab: 拦截规则 ---- */}
|
||||
<TabsContent value="rules" className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
按优先级(数字越大越先)逐条匹配,首条命中的规则生效
|
||||
</p>
|
||||
<Button onClick={openNew} size="sm" className="shrink-0">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
|
||||
) : rules.length === 0 ? (
|
||||
@@ -404,8 +654,15 @@ export default function InterceptPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- tab: 模型配置 ---- */}
|
||||
<TabsContent value="judge" className="mt-4">
|
||||
<JudgeCard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ---- editor sheet ---- */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
InterceptApprovalRow,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
JudgeConfig,
|
||||
LLMPoolStatus,
|
||||
LLMProfile,
|
||||
LLMRecordDetail,
|
||||
@@ -864,6 +865,10 @@ export const api = {
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
},
|
||||
|
||||
// ---- intercept LLM judge (模型兜底审批,全局配置) ----
|
||||
interceptGetJudgeConfig: () => get<JudgeConfig>("/intercept/judge"),
|
||||
interceptSetJudgeConfig: (cfg: JudgeConfig) => put<{ ok: boolean }>("/intercept/judge", cfg),
|
||||
|
||||
// ---- commands (tool execution history, any tool) ----
|
||||
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
@@ -789,14 +789,14 @@ export const interceptRules: InterceptRule[] = [
|
||||
];
|
||||
|
||||
export const interceptPending: InterceptPending[] = [
|
||||
{ id: 101, rule_id: 1, task_id: "t-acme-web", agent_name: "work#3", tool_name: "bash", tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, status: "pending", created_at: T("2026-07-26T03:58:00Z") },
|
||||
{ id: 101, rule_id: 1, task_id: "t-acme-web", agent_name: "work#3", tool_name: "bash", tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, status: "pending", reason: "数据外泄门控:整库导出需人工审批", created_at: T("2026-07-26T03:58:00Z") },
|
||||
];
|
||||
|
||||
export const interceptHistory: InterceptApprovalRow[] = [
|
||||
{ id: 90, rule_id: 1, task_id: "t-acme-web", agent_name: "work#1", tool_name: "bash", tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, status: "denied", created_at: T("2026-07-25T20:00:00Z"), decided_at: T("2026-07-25T20:01:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
|
||||
{ id: 91, task_id: "t-acme-api", agent_name: "work#2", tool_name: "bash", tool_input: { command: "nmap -sV 203.0.113.11" }, status: "allowed", created_at: T("2026-07-25T18:00:00Z"), decided_at: T("2026-07-25T18:00:05Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
|
||||
{ id: 92, rule_id: 2, task_id: "t-acme-web", agent_name: "work#13", tool_name: "bash", tool_input: { command: "impacket-secretsdump acme.local/[email protected] -just-dc" }, status: "allowed", created_at: T("2026-07-26T00:05:00Z"), decided_at: T("2026-07-26T00:06:00Z"), conv_title: "", conv_agent_key: "", rule_name: "外发流量直接拒绝" },
|
||||
{ id: 93, rule_id: 1, task_id: "t-acme-web", agent_name: "work#7", tool_name: "bash", tool_input: { command: "java -jar JNDIExploit.jar -i <vps> -l 1389" }, status: "allowed", created_at: T("2026-07-25T15:14:00Z"), decided_at: T("2026-07-25T15:15:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
|
||||
{ id: 90, rule_id: 1, task_id: "t-acme-web", agent_name: "work#1", tool_name: "bash", tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, status: "denied", reason: "破坏性命令需审批", created_at: T("2026-07-25T20:00:00Z"), decided_at: T("2026-07-25T20:01:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" },
|
||||
{ id: 91, task_id: "t-acme-api", agent_name: "work#2", tool_name: "bash", tool_input: { command: "rm -rf /var/www/html" }, status: "denied", reason: "[模型] 删除目标生产文件(D4)", created_at: T("2026-07-25T18:00:00Z"), decided_at: T("2026-07-25T18:00:05Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
|
||||
{ id: 92, rule_id: 2, task_id: "t-acme-web", agent_name: "work#13", tool_name: "bash", tool_input: { command: "impacket-secretsdump acme.local/[email protected] -just-dc" }, status: "allowed", reason: "外发流量直接拒绝", created_at: T("2026-07-26T00:05:00Z"), decided_at: T("2026-07-26T00:06:00Z"), conv_title: "", conv_agent_key: "", rule_name: "外发流量直接拒绝" },
|
||||
{ id: 93, task_id: "t-acme-web", agent_name: "work#7", tool_name: "bash", tool_input: { command: "mysql -e \"DROP TABLE users_bak_0921\"" }, status: "pending", reason: "[模型] 疑似备份表,无法确定是否生产数据", created_at: T("2026-07-25T15:14:00Z"), conv_title: "", conv_agent_key: "", rule_name: "" },
|
||||
];
|
||||
|
||||
// ── Conversations (chat) ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -943,6 +943,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "intercept" && seg[1] === "task")
|
||||
return { items: D.interceptHistory.filter((r) => r.task_id === seg[2]) };
|
||||
if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] };
|
||||
if (path === "/intercept/judge" && m === "GET")
|
||||
return {
|
||||
enabled: false,
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
};
|
||||
if (path === "/intercept/judge" && m === "PUT") return { ok: true };
|
||||
|
||||
// ── 写操作兜底:成功但不落库 ──
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true };
|
||||
|
||||
@@ -916,10 +916,22 @@ export interface InterceptPending {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tool_input: Record<string, any>;
|
||||
status: "pending" | "allowed" | "denied" | "timeout";
|
||||
reason: string; // 规则 message 或模型判定理由(模型判定带 [模型] 前缀)
|
||||
decided_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// JudgeConfig: 模型兜底审批(仅当没有任何拦截规则命中时由模型判断)的全局配置。
|
||||
export interface JudgeConfig {
|
||||
enabled: boolean;
|
||||
profile_id: number; // 0 = 跟随激活/默认配置
|
||||
prompt: string; // 判定提示词;GET 未设置时后端回填内置模板全文
|
||||
timeout_seconds: number; // 模型调用超时
|
||||
fail_action: "allow" | "ask" | "deny"; // 模型出错/超时/不可解析时的回退
|
||||
ask_timeout_seconds: number; // 模型判 ask 转人工后的审批等待超时
|
||||
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
||||
}
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
export interface InterceptApprovalRow extends InterceptPending {
|
||||
conv_title: string; // "" if no linked conversation
|
||||
|
||||
Reference in new issue
Block a user