feat: 新增资产拦截规则(全局黑名单)增删改查

新增独立于命令拦截(intercept_rules)的资产拦截功能,仅提供规则的
存储与管理;具体匹配/拦截逻辑另行实现。

- db: 新增 asset_intercept_rules 表(schema.sql,幂等)与 CRUD 层
  (db/asset_intercept.go);kind 支持全等/模糊的 域名·IP·URL 及 CIDR
- db: 首启动 seed 内置模糊拦截政府(.gov/.gov.cn)与教育(.edu/.edu.cn)
  网站,settings 标记位门控,可禁用/删除且重启不复活
- server: /api/asset-intercept/rules 的 list/create/update/delete/toggle;
  cidr、exact_ip 做格式校验
- web: 新增「资产拦截」页(/system/intercept/assets)与侧边栏入口、
  api 与类型定义
This commit is contained in:
Autumn-27 committed 2026-09-19 07:58:23 -04:00
1 parent a590b415ba
commit dfc9d2fa2e
9 files changed
+687

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
package db
import "time"
// AssetInterceptRule is one row of asset_intercept_rules — a global asset
// blocklist entry. Unlike intercept_rules (which matches tool name / input),
// these match the *target asset*: an exact/fuzzy domain·ip·url, or a CIDR range.
// This layer only stores rules; the matching/enforcement logic lives elsewhere.
type AssetInterceptRule struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Kind string `json:"kind"` // exact_domain|exact_ip|exact_url|fuzzy_domain|fuzzy_ip|fuzzy_url|cidr
Pattern string `json:"pattern"`
Note string `json:"note"`
Builtin bool `json:"builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const assetInterceptRuleCols = `id, enabled, kind, pattern, note, builtin, created_at, updated_at`
func scanAssetInterceptRule(row interface{ Scan(...any) error }) (AssetInterceptRule, error) {
var r AssetInterceptRule
err := row.Scan(&r.ID, &r.Enabled, &r.Kind, &r.Pattern, &r.Note, &r.Builtin, &r.CreatedAt, &r.UpdatedAt)
return r, err
}
// ListAssetInterceptRules returns all rules, built-ins first then newest first.
func (d *DB) ListAssetInterceptRules() ([]AssetInterceptRule, error) {
rows, err := d.Query(`SELECT ` + assetInterceptRuleCols + ` FROM asset_intercept_rules ORDER BY builtin DESC, id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AssetInterceptRule
for rows.Next() {
r, err := scanAssetInterceptRule(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// CreateAssetInterceptRule inserts a new user rule (builtin is always false here).
func (d *DB) CreateAssetInterceptRule(kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
row := d.QueryRow(`
INSERT INTO asset_intercept_rules(enabled, kind, pattern, note, builtin)
VALUES ($1, $2, $3, $4, false)
RETURNING `+assetInterceptRuleCols,
enabled, kind, pattern, note)
return scanAssetInterceptRule(row)
}
// UpdateAssetInterceptRule replaces the editable fields of an existing rule.
func (d *DB) UpdateAssetInterceptRule(id int64, kind, pattern, note string, enabled bool) (AssetInterceptRule, error) {
row := d.QueryRow(`
UPDATE asset_intercept_rules
SET enabled=$2, kind=$3, pattern=$4, note=$5
WHERE id=$1
RETURNING `+assetInterceptRuleCols,
id, enabled, kind, pattern, note)
return scanAssetInterceptRule(row)
}
// DeleteAssetInterceptRule removes a rule (built-in rules are deletable too).
func (d *DB) DeleteAssetInterceptRule(id int64) error {
_, err := d.Exec(`DELETE FROM asset_intercept_rules WHERE id=$1`, id)
return err
}
// ToggleAssetInterceptRule flips the enabled state of a rule.
func (d *DB) ToggleAssetInterceptRule(id int64, enabled bool) error {
_, err := d.Exec(`UPDATE asset_intercept_rules SET enabled=$2 WHERE id=$1`, id, enabled)
return err
}
+32
View File
@@ -261,9 +261,41 @@ ON CONFLICT (name) DO NOTHING`,
if err := d.seedDefaultInterceptRulesV2(); err != nil { if err := d.seedDefaultInterceptRulesV2(); err != nil {
return fmt.Errorf("seed intercept rules v2: %w", err) return fmt.Errorf("seed intercept rules v2: %w", err)
} }
if err := d.seedDefaultAssetInterceptRules(); err != nil {
return fmt.Errorf("seed asset intercept rules: %w", err)
}
return nil return nil
} }
// seedDefaultAssetInterceptRules inserts the built-in asset blocklist (fuzzy
// domain matches for government / education sites) once on first startup. Gated
// by a settings flag so a user's later disable/delete is never resurrected on
// restart — same policy as the intercept-rule seed.
func (d *DB) seedDefaultAssetInterceptRules() error {
if v, _, _ := d.GetSetting("asset_intercept_default_rules_v1"); v == "done" {
return nil
}
rules := []struct {
kind string
pattern string
note string
}{
{"fuzzy_domain", ".gov", "[内置] 政府网站 (.gov)"},
{"fuzzy_domain", ".gov.cn", "[内置] 政府网站 (.gov.cn)"},
{"fuzzy_domain", ".edu", "[内置] 教育网站 (.edu)"},
{"fuzzy_domain", ".edu.cn", "[内置] 教育网站 (.edu.cn)"},
}
for _, r := range rules {
if _, err := d.Exec(`
INSERT INTO asset_intercept_rules(enabled, kind, pattern, note, builtin)
VALUES (true, $1, $2, $3, true)
ON CONFLICT DO NOTHING`, r.kind, r.pattern, r.note); err != nil {
return fmt.Errorf("asset rule %q: %w", r.pattern, err)
}
}
return d.SetSetting("asset_intercept_default_rules_v1", "done")
}
// builtinSkillVisibility maps a shipped skill's directory name → the built-in // builtinSkillVisibility maps a shipped skill's directory name → the built-in
// agent keys that should see it by default. The skill FILES themselves live on the // agent keys that should see it by default. The skill FILES themselves live on the
// filesystem (SkillDir, loaded by norma at runtime); DB only carries this visibility // filesystem (SkillDir, loaded by norma at runtime); DB only carries this visibility
+28
View File
@@ -1220,3 +1220,31 @@ CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(s
-- Additive v3 archive fields; old archives restore these as empty objects. -- Additive v3 archive fields; old archives restore these as empty objects.
ALTER TABLE side_question_sessions ADD COLUMN IF NOT EXISTS memory JSONB NOT NULL DEFAULT '{}'; ALTER TABLE side_question_sessions ADD COLUMN IF NOT EXISTS memory JSONB NOT NULL DEFAULT '{}';
ALTER TABLE side_question_requests ADD COLUMN IF NOT EXISTS context_info JSONB NOT NULL DEFAULT '{}'; ALTER TABLE side_question_requests ADD COLUMN IF NOT EXISTS context_info JSONB NOT NULL DEFAULT '{}';
-- =====================================================================
-- 资产拦截规则(全局黑名单)
-- 独立于 §K 命令拦截(intercept_rules):intercept_rules 匹配工具名/入参文本,
-- 这张表匹配「目标资产」——全等/模糊的域名·IP·URL 以及 CIDR 网段。
-- 仅存规则;具体的匹配/拦截逻辑在别处实现。
-- kind 七种:
-- exact_domain / exact_ip / exact_url —— 全等匹配
-- fuzzy_domain / fuzzy_ip / fuzzy_url —— 模糊匹配
-- cidr —— CIDR 网段
-- =====================================================================
CREATE TABLE IF NOT EXISTS asset_intercept_rules (
id BIGSERIAL PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
kind TEXT NOT NULL CHECK (kind IN (
'exact_domain', 'exact_ip', 'exact_url',
'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url',
'cidr')),
pattern TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
builtin BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_asset_intercept_enabled ON asset_intercept_rules(enabled);
DROP TRIGGER IF EXISTS trg_asset_intercept_rules_upd ON asset_intercept_rules;
CREATE TRIGGER trg_asset_intercept_rules_upd BEFORE UPDATE ON asset_intercept_rules
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+156
View File
@@ -0,0 +1,156 @@
package server
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/Autumn-27/artex/db"
)
// --- asset intercept rule CRUD ---
//
// Global asset blocklist: exact/fuzzy domain·ip·url + CIDR. This layer only
// persists rules; the actual match/enforcement logic lives elsewhere.
func (s *Server) assetInterceptListRules(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
rules, err := pg.ListAssetInterceptRules()
if err != nil {
writeErr(w, 500, err.Error())
return
}
if rules == nil {
rules = []db.AssetInterceptRule{}
}
writeJSON(w, 200, map[string]any{"rules": rules})
}
func (s *Server) assetInterceptCreateRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var req assetInterceptRuleReq
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := validateAssetInterceptRuleReq(&req); err != nil {
writeErr(w, 400, err.Error())
return
}
rule, err := pg.CreateAssetInterceptRule(req.Kind, req.Pattern, req.Note, req.Enabled)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, rule)
}
func (s *Server) assetInterceptUpdateRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "bad rule id")
return
}
var req assetInterceptRuleReq
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := validateAssetInterceptRuleReq(&req); err != nil {
writeErr(w, 400, err.Error())
return
}
rule, err := pg.UpdateAssetInterceptRule(id, req.Kind, req.Pattern, req.Note, req.Enabled)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, rule)
}
func (s *Server) assetInterceptDeleteRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "bad rule id")
return
}
if err := pg.DeleteAssetInterceptRule(id); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"deleted": id})
}
func (s *Server) assetInterceptToggleRule(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "bad rule id")
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
if err := pg.ToggleAssetInterceptRule(id, req.Enabled); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true, "enabled": req.Enabled})
}
// --- helpers ---
type assetInterceptRuleReq struct {
Enabled bool `json:"enabled"`
Kind string `json:"kind"`
Pattern string `json:"pattern"`
Note string `json:"note"`
}
// validateAssetInterceptRuleReq normalizes and validates a rule. It trims the
// pattern and, for the strictly-formatted kinds (exact_ip / cidr), rejects
// malformed values; fuzzy kinds and domain/url patterns are left as free text
// (the enforcement layer decides how to interpret them).
func validateAssetInterceptRuleReq(req *assetInterceptRuleReq) error {
req.Pattern = strings.TrimSpace(req.Pattern)
if req.Pattern == "" {
return fmt.Errorf("pattern 不能为空")
}
switch req.Kind {
case "exact_domain", "exact_url", "fuzzy_domain", "fuzzy_ip", "fuzzy_url":
// free-form, no format check
case "exact_ip":
if net.ParseIP(req.Pattern) == nil {
return fmt.Errorf("exact_ip 不是有效 IP 地址:%s", req.Pattern)
}
case "cidr":
if _, _, err := net.ParseCIDR(req.Pattern); err != nil {
return fmt.Errorf("cidr 不是有效网段(形如 192.168.0.0/16):%s", req.Pattern)
}
default:
return fmt.Errorf("kind 无效:%s", req.Kind)
}
return nil
}
+8
View File
@@ -890,6 +890,14 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/intercept/rules/{id}", s.interceptUpdateRule) mux.HandleFunc("PUT /api/intercept/rules/{id}", s.interceptUpdateRule)
mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule) mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule)
mux.HandleFunc("POST /api/intercept/rules/{id}/toggle", s.interceptToggleRule) mux.HandleFunc("POST /api/intercept/rules/{id}/toggle", s.interceptToggleRule)
// 资产拦截规则管理(全局黑名单:域名/IP/URL/CIDR)
mux.HandleFunc("GET /api/asset-intercept/rules", s.assetInterceptListRules)
mux.HandleFunc("POST /api/asset-intercept/rules", s.assetInterceptCreateRule)
mux.HandleFunc("PUT /api/asset-intercept/rules/{id}", s.assetInterceptUpdateRule)
mux.HandleFunc("DELETE /api/asset-intercept/rules/{id}", s.assetInterceptDeleteRule)
mux.HandleFunc("POST /api/asset-intercept/rules/{id}/toggle", s.assetInterceptToggleRule)
mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending) mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending)
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne) mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide) mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
@@ -0,0 +1,352 @@
"use client";
import * as React from "react";
import { BanIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { AssetInterceptKind, AssetInterceptRule } from "@/lib/types";
// ---- kind 元信息 ----
const KIND_OPTIONS: { value: AssetInterceptKind; label: string; group: string; placeholder: string }[] = [
{ value: "exact_domain", label: "域名(全等)", group: "全等匹配", placeholder: "example.gov.cn" },
{ value: "exact_ip", label: "IP(全等)", group: "全等匹配", placeholder: "203.0.113.10" },
{ value: "exact_url", label: "URL(全等)", group: "全等匹配", placeholder: "https://example.gov.cn/login" },
{ value: "fuzzy_domain", label: "域名(模糊)", group: "模糊匹配", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", label: "IP(模糊)", group: "模糊匹配", placeholder: "203.0.113." },
{ value: "fuzzy_url", label: "URL(模糊)", group: "模糊匹配", placeholder: "/admin" },
{ value: "cidr", label: "CIDR 网段", group: "网段", placeholder: "192.168.0.0/16" },
];
const KIND_LABEL: Record<AssetInterceptKind, string> = Object.fromEntries(
KIND_OPTIONS.map((o) => [o.value, o.label]),
) as Record<AssetInterceptKind, string>;
const KIND_GROUPS = ["全等匹配", "模糊匹配", "网段"];
function KindBadge({ kind }: { kind: AssetInterceptKind }) {
const fuzzy = kind.startsWith("fuzzy_");
const cidr = kind === "cidr";
return (
<Badge
variant="outline"
className={
cidr
? "border-sky-400 text-sky-600"
: fuzzy
? "border-amber-400 text-amber-600"
: "border-emerald-400 text-emerald-600"
}
>
{KIND_LABEL[kind]}
</Badge>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
{children}
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
// ---- form state ----
type RuleForm = {
enabled: boolean;
kind: AssetInterceptKind;
pattern: string;
note: string;
};
const defaultForm = (): RuleForm => ({ enabled: true, kind: "fuzzy_domain", pattern: "", note: "" });
// 前端轻校验(与后端一致:仅 exact_ip / cidr 做格式校验,其余交后端)。
function frontValidate(form: RuleForm): string | null {
const p = form.pattern.trim();
if (!p) return "匹配内容不能为空";
if (form.kind === "cidr" && !/^[0-9a-fA-F:.]+\/\d{1,3}$/.test(p)) {
return "CIDR 格式无效,形如 192.168.0.0/16";
}
return null;
}
// ---- page ----
export default function AssetInterceptPage() {
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<AssetInterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
try {
setRules(await api.assetInterceptRules());
} catch {
toast.error("加载资产拦截规则失败");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
load();
}, [load]);
function set(patch: Partial<RuleForm>) {
setForm((f) => ({ ...f, ...patch }));
}
function openNew() {
setEditing(null);
setForm(defaultForm());
setOpen(true);
}
function openEdit(rule: AssetInterceptRule) {
setEditing(rule);
setForm({ enabled: rule.enabled, kind: rule.kind, pattern: rule.pattern, note: rule.note });
setOpen(true);
}
async function handleSave() {
const err = frontValidate(form);
if (err) {
toast.error(err);
return;
}
const payload = { ...form, pattern: form.pattern.trim() };
setSaving(true);
try {
if (editing) {
await api.updateAssetInterceptRule(editing.id, payload);
toast.success("规则已更新");
} else {
await api.createAssetInterceptRule(payload);
toast.success("规则已创建");
}
setOpen(false);
load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(rule: AssetInterceptRule) {
if (!window.confirm(`确定删除资产拦截规则「${rule.pattern}」?`)) return;
try {
await api.deleteAssetInterceptRule(rule.id);
toast.success("规则已删除");
load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: AssetInterceptRule) {
try {
await api.toggleAssetInterceptRule(rule.id, !rule.enabled);
load();
} catch (e) {
toast.error((e as Error).message);
}
}
const placeholder = KIND_OPTIONS.find((o) => o.value === form.kind)?.placeholder ?? "";
return (
<div className="flex flex-1 flex-col gap-5 p-6">
{/* header */}
<div className="flex items-center gap-2.5">
<BanIcon 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">
全局资产黑名单:命中的域名 / IP / URL / 网段将被拦截,不对其执行任何操作
</p>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
支持全等与模糊匹配的域名 / IP / URL,以及 CIDR 网段;默认内置模糊拦截政府(.gov / .gov.cn)与教育(.edu /
.edu.cn)网站
</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 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<BanIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无资产拦截规则</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
新建第一条规则
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[130px]">类型</TableHead>
<TableHead>匹配内容</TableHead>
<TableHead>备注</TableHead>
<TableHead className="w-[64px] text-center">启用</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<KindBadge kind={rule.kind} />
</TableCell>
<TableCell className="max-w-[280px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
{rule.builtin && (
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px]">
内置
</Badge>
)}
<span className="truncate">{rule.note}</span>
</div>
</TableCell>
<TableCell className="text-center">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* editor sheet */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? "编辑资产拦截规则" : "新建资产拦截规则"}</SheetTitle>
<SheetDescription className="text-xs">命中此规则的目标资产会被全局拦截</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label="匹配类型">
<Select value={form.kind} onValueChange={(v) => set({ kind: v as AssetInterceptKind })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_GROUPS.map((g) => (
<React.Fragment key={g}>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{g}
</div>
{KIND_OPTIONS.filter((o) => o.group === g).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</Field>
<Field
label="匹配内容"
hint={
form.kind === "cidr"
? "CIDR 网段,形如 192.168.0.0/16"
: form.kind.startsWith("fuzzy_")
? "模糊匹配:目标包含此内容即命中"
: "全等匹配:目标需与此内容完全一致"
}
>
<Input
placeholder={placeholder}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
/>
</Field>
<Field label="备注(可选)">
<Textarea
placeholder="说明这条规则的用途"
value={form.note}
onChange={(e) => set({ note: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
<Separator />
<div className="flex items-center gap-3">
<Switch id="asset-rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
<Label htmlFor="asset-rule-enabled" className="cursor-pointer">
启用此规则
</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中…" : "保存"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
+11
View File
@@ -14,6 +14,7 @@ import type {
AgentTrigger, AgentTrigger,
ArchiveBatchItem, ArchiveBatchItem,
Asset, Asset,
AssetInterceptRule,
Audit, Audit,
BatchCategoryItem, BatchCategoryItem,
BatchControlItem, BatchControlItem,
@@ -1099,6 +1100,16 @@ export const api = {
toggleInterceptRule: (id: number, enabled: boolean) => toggleInterceptRule: (id: number, enabled: boolean) =>
post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }), post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }),
// ---- asset intercept rules(资产拦截:全局黑名单) ----
assetInterceptRules: () => get<{ rules: AssetInterceptRule[] }>("/asset-intercept/rules").then((r) => arr(r.rules)),
createAssetInterceptRule: (rule: Pick<AssetInterceptRule, "enabled" | "kind" | "pattern" | "note">) =>
post<AssetInterceptRule>("/asset-intercept/rules", rule),
updateAssetInterceptRule: (id: number, rule: Pick<AssetInterceptRule, "enabled" | "kind" | "pattern" | "note">) =>
put<AssetInterceptRule>(`/asset-intercept/rules/${id}`, rule),
deleteAssetInterceptRule: (id: number) => del<{ deleted: number }>(`/asset-intercept/rules/${id}`),
toggleAssetInterceptRule: (id: number, enabled: boolean) =>
post<{ ok: boolean; enabled: boolean }>(`/asset-intercept/rules/${id}/toggle`, { enabled }),
// ---- intercept pending (ask) ---- // ---- intercept pending (ask) ----
interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)), interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)),
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`), interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
+21
View File
@@ -1191,6 +1191,27 @@ export interface InterceptRule {
updated_at: string; updated_at: string;
} }
// ---- Asset Intercept Rules(资产拦截:全局黑名单) ----
export type AssetInterceptKind =
| "exact_domain"
| "exact_ip"
| "exact_url"
| "fuzzy_domain"
| "fuzzy_ip"
| "fuzzy_url"
| "cidr";
export interface AssetInterceptRule {
id: number;
enabled: boolean;
kind: AssetInterceptKind;
pattern: string;
note: string;
builtin: boolean;
created_at: string;
updated_at: string;
}
export interface InterceptPending { export interface InterceptPending {
decision_source?: "rule" | "model" | "unknown" | ""; decision_source?: "rule" | "model" | "unknown" | "";
id: number; id: number;
@@ -1,5 +1,6 @@
import { import {
Activity, Activity,
Ban,
Bot, Bot,
Brain, Brain,
Bug, Bug,
@@ -86,6 +87,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles }, { id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench }, { id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert }, { id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
{ id: "asset-intercept", title: "资产拦截", url: "/system/intercept/assets", icon: Ban },
{ id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList }, { id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },
{ id: "logs", title: "日志", url: "/system/logs", icon: ScrollText }, { id: "logs", title: "日志", url: "/system/logs", icon: ScrollText },
{ id: "settings", title: "系统配置", url: "/system/settings", icon: Settings2 }, { id: "settings", title: "系统配置", url: "/system/settings", icon: Settings2 },