From dfc9d2fa2ef9d4b3c02f2067a405b34312ee4257 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:58:23 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E8=A7=84=E5=88=99=EF=BC=88=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E9=BB=91=E5=90=8D=E5=8D=95=EF=BC=89=E5=A2=9E=E5=88=A0=E6=94=B9?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立于命令拦截(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 与类型定义 --- db/asset_intercept.go | 77 ++++ db/db.go | 32 ++ db/schema.sql | 28 ++ server/asset_intercept.go | 156 ++++++++ server/server.go | 8 + .../(main)/system/intercept/assets/page.tsx | 352 ++++++++++++++++++ web/src/lib/api.ts | 11 + web/src/lib/types.ts | 21 ++ web/src/navigation/sidebar/sidebar-items.ts | 2 + 9 files changed, 687 insertions(+) create mode 100644 db/asset_intercept.go create mode 100644 server/asset_intercept.go create mode 100644 web/src/app/(main)/system/intercept/assets/page.tsx diff --git a/db/asset_intercept.go b/db/asset_intercept.go new file mode 100644 index 0000000..32771c7 --- /dev/null +++ b/db/asset_intercept.go @@ -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 +} diff --git a/db/db.go b/db/db.go index 20ec514..8985235 100644 --- a/db/db.go +++ b/db/db.go @@ -261,9 +261,41 @@ ON CONFLICT (name) DO NOTHING`, if err := d.seedDefaultInterceptRulesV2(); err != nil { 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 } +// 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 // 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 diff --git a/db/schema.sql b/db/schema.sql index 561e450..97a4d04 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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. 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 '{}'; + +-- ===================================================================== +-- 资产拦截规则(全局黑名单) +-- 独立于 §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(); diff --git a/server/asset_intercept.go b/server/asset_intercept.go new file mode 100644 index 0000000..b685325 --- /dev/null +++ b/server/asset_intercept.go @@ -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 +} diff --git a/server/server.go b/server/server.go index efb9c28..9883729 100644 --- a/server/server.go +++ b/server/server.go @@ -890,6 +890,14 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/intercept/rules/{id}", s.interceptUpdateRule) mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule) 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/{id}", s.interceptGetOne) mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide) diff --git a/web/src/app/(main)/system/intercept/assets/page.tsx b/web/src/app/(main)/system/intercept/assets/page.tsx new file mode 100644 index 0000000..392db21 --- /dev/null +++ b/web/src/app/(main)/system/intercept/assets/page.tsx @@ -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 = Object.fromEntries( + KIND_OPTIONS.map((o) => [o.value, o.label]), +) as Record; + +const KIND_GROUPS = ["全等匹配", "模糊匹配", "网段"]; + +function KindBadge({ kind }: { kind: AssetInterceptKind }) { + const fuzzy = kind.startsWith("fuzzy_"); + const cidr = kind === "cidr"; + return ( + + {KIND_LABEL[kind]} + + ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +// ---- 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([]); + const [loading, setLoading] = React.useState(true); + const [open, setOpen] = React.useState(false); + const [editing, setEditing] = React.useState(null); + const [form, setForm] = React.useState(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) { + 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 ( +
+ {/* header */} +
+ +
+

资产拦截

+

+ 全局资产黑名单:命中的域名 / IP / URL / 网段将被拦截,不对其执行任何操作 +

+
+
+ +
+

+ 支持全等与模糊匹配的域名 / IP / URL,以及 CIDR 网段;默认内置模糊拦截政府(.gov / .gov.cn)与教育(.edu / + .edu.cn)网站 +

+ +
+ + + + {loading ? ( +

加载中…

+ ) : rules.length === 0 ? ( +
+ +

暂无资产拦截规则

+ +
+ ) : ( + + + + 类型 + 匹配内容 + 备注 + 启用 + + + + + {rules.map((rule) => ( + + + + + + + {rule.pattern} + + + +
+ {rule.builtin && ( + + 内置 + + )} + {rule.note} +
+
+ + handleToggle(rule)} /> + + +
+ + +
+
+
+ ))} +
+
+ )} +
+
+ + {/* editor sheet */} + + + + {editing ? "编辑资产拦截规则" : "新建资产拦截规则"} + 命中此规则的目标资产会被全局拦截 + + +
+ + + + + + set({ pattern: e.target.value })} + /> + + + +