From 68c1a7272a8c739def86ad95e79a112f3adca269 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 9 Oct 2026 10:36:02 +0000 Subject: [PATCH] Keep tracked-change and hyperlink text in DOCX and add an exec hard switch Why: The DOCX parser only read w:r runs that were direct children of w:p, so text inside w:ins, w:hyperlink and w:fldSimple was dropped with no warning, violating the no-silent-degradation rule. Separately, runtime.exec is a user-writable setting, so multi-user deployments could not reliably turn the exec primitives off. Evidence: SELF - red tests TestParseDocxTrackedChangesAndHyperlinks and TestExecHardDisabledOverridesSettings failed before and pass after; backend go test ./... (32 packages) passes; a mock-mode binary started with --disable-exec-tools hides run_shell/run_python/register_artifact from /api/tools even after the user sets runtime.exec=true. Impact: DOCX paragraphs are decoded from the token stream at any depth; deleted/moved-from text, field codes and mc:Fallback are excluded and revised documents carry an explicit warning. New Options.DisableExecTools (SurfaceVersion 2.5.0-experimental), CLI --disable-exec-tools, config key disableExecTools and env AGENT_SYSTEM_DISABLE_EXEC_TOOLS; zero value keeps current behavior. Refs: Agent-System-RDM docs/proposals/2026-10-09-V22-review-and-implementation-plan.md (G0-7, B-5) --- cmd/agent-system/main.go | 9 +- internal/app/app.go | 11 +- internal/config/config.go | 33 +++-- internal/config/config_test.go | 32 +++++ internal/ingest/ingest.go | 166 +++++++++++++++++------- internal/ingest/ingest_test.go | 62 +++++++++ internal/runmgr/runmgr.go | 2 +- internal/server/server.go | 2 +- internal/tools/exec.go | 37 +++++- internal/tools/exec_interpreter.go | 5 +- internal/tools/exec_interpreter_test.go | 37 ++++++ pkg/extension/extension.go | 7 +- pkg/extension/link/link.go | 3 +- 13 files changed, 341 insertions(+), 65 deletions(-) diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 8d9b1b3..5692e74 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -44,13 +44,14 @@ func main() { instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)") insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)") allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验") + noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)") fs.Parse(os.Args[1:]) cliSet := visited(fs) resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance, - InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, + InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec, }, cliSet) if err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) @@ -60,6 +61,7 @@ func main() { Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock, StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode, InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(), + DisableExecTools: resolved.DisableExecTools, }); err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) os.Exit(1) @@ -76,6 +78,8 @@ func visited(fs *flag.FlagSet) map[string]bool { name = "insecureListen" case "allowed-hosts": name = "allowedHosts" + case "disable-exec-tools": + name = "disableExecTools" } set[name] = true }) @@ -100,11 +104,12 @@ func runConfig(args []string) int { instance := fs.String("instance", "", "") insecure := fs.Bool("insecure-listen", false, "") allowedHosts := fs.String("allowed-hosts", "", "") + noExec := fs.Bool("disable-exec-tools", false, "") fs.Parse(args[1:]) resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance, - InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, + InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec, }, visited(fs)) if err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) diff --git a/internal/app/app.go b/internal/app/app.go index 6d1e1cb..89016d8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -72,6 +72,10 @@ type Options struct { // DisableConvCardMirror EXT-12:true 时会话卡不镜像进项目公共流(不发布 // 即不持久,重放不含会话卡);零值=false=现状全量镜像。 DisableConvCardMirror bool + // DisableExecTools 启动级硬关闭执行原语(run_shell / run_python / + // register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖; + // 零值=false=现状。 + DisableExecTools bool } // Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。 @@ -118,7 +122,12 @@ func Run(opt Options) error { } // 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥, // runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法 - // 在提示词库 base.deliverables,不在系统工具里。 + // 在提示词库 base.deliverables,不在系统工具里。启动参数 DisableExecTools + // 为不受用户设置影响的硬关闭(多用户部署的默认安全档)。 + tools.SetExecDisabled(opt.DisableExecTools) + if opt.DisableExecTools { + log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)") + } if err := tools.RegisterExecTools(reg); err != nil { return fmt.Errorf("exec tools: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index ba40f89..6d3cd23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,8 @@ type Config struct { Instance string `json:"instance"` InsecureListen bool `json:"insecureListen"` AllowedHostsRaw string `json:"allowedHosts"` + // DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。 + DisableExecTools bool `json:"disableExecTools"` } var defaults = Config{ @@ -37,16 +39,17 @@ var defaults = Config{ // envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。 var envKey = map[string]string{ - "addr": "AGENT_SYSTEM_ADDR", - "data": "AGENT_SYSTEM_DATA", - "plugins": "AGENT_SYSTEM_PLUGINS", - "dsn": "AGENT_SYSTEM_DSN", - "artifacts": "AGENT_SYSTEM_ARTIFACTS", - "auth": "AGENT_SYSTEM_AUTH", - "mock": "AGENT_SYSTEM_MOCK", - "instance": "AGENT_SYSTEM_INSTANCE", - "insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN", - "allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS", + "addr": "AGENT_SYSTEM_ADDR", + "data": "AGENT_SYSTEM_DATA", + "plugins": "AGENT_SYSTEM_PLUGINS", + "dsn": "AGENT_SYSTEM_DSN", + "artifacts": "AGENT_SYSTEM_ARTIFACTS", + "auth": "AGENT_SYSTEM_AUTH", + "mock": "AGENT_SYSTEM_MOCK", + "instance": "AGENT_SYSTEM_INSTANCE", + "insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN", + "allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS", + "disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS", } // Source 配置来源层级(数值越大优先级越高)。 @@ -165,6 +168,8 @@ func loadFile(path string) (Config, map[string]bool, error) { dst = &c.InsecureListen case "allowedHosts": dst = &c.AllowedHostsRaw + case "disableExecTools": + dst = &c.DisableExecTools default: return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k) } @@ -199,13 +204,15 @@ func stringField(c Config, f string) string { } func isBoolField(f string) bool { - return f == "mock" || f == "insecureListen" + return f == "mock" || f == "insecureListen" || f == "disableExecTools" } func boolField(c Config, f string) bool { switch f { case "insecureListen": return c.InsecureListen + case "disableExecTools": + return c.DisableExecTools default: return c.Mock } @@ -215,6 +222,8 @@ func setBool(c *Config, f string, v bool) { switch f { case "insecureListen": c.InsecureListen = v + case "disableExecTools": + c.DisableExecTools = v default: c.Mock = v } @@ -259,7 +268,7 @@ func (r Resolved) Explain(w io.Writer, file string) { fmt.Fprintln(w, "config file: (none)") } fmt.Fprintln(w, "field value source") - for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts"} { + for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools"} { v := stringField(r.Config, f) if isBoolField(f) { v = fmt.Sprintf("%t", boolField(r.Config, f)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d9acda1..1ddfaf1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -123,6 +123,38 @@ func TestEnvBool(t *testing.T) { } } +// disableExecTools:默认 false;文件、环境、CLI 三层均可打开,CLI 优先。 +func TestDisableExecToolsLayers(t *testing.T) { + r, err := Load("", noEnv, Config{}, nil) + if err != nil || r.DisableExecTools { + t.Fatalf("default must be false: %v %+v", err, r.Config) + } + f := fileWith(t, `{"disableExecTools":true}`) + r, err = Load(f, noEnv, Config{}, nil) + if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcFile { + t.Fatalf("file layer: %v %+v src=%v", err, r.Config, r.SourceOf("disableExecTools")) + } + env := func(k string) (string, bool) { + if k == "AGENT_SYSTEM_DISABLE_EXEC_TOOLS" { + return "1", true + } + return "", false + } + r, err = Load("", env, Config{}, nil) + if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcEnv { + t.Fatalf("env layer: %v %+v", err, r.Config) + } + r, err = Load("", env, Config{DisableExecTools: false}, map[string]bool{"disableExecTools": true}) + if err != nil || r.DisableExecTools || r.SourceOf("disableExecTools") != SrcCLI { + t.Fatalf("cli must win: %v %+v", err, r.Config) + } + var out bytes.Buffer + r.Explain(&out, "") + if !bytes.Contains(out.Bytes(), []byte("disableExecTools")) { + t.Fatalf("explain must list disableExecTools:\n%s", out.String()) + } +} + // explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。 func TestExplainRedactsDSN(t *testing.T) { env := func(k string) (string, bool) { diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index fe2c2f8..e409c49 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -33,11 +33,16 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument { doc.Meta.Confidence = 1.0 case "docx": doc.Meta.Parser = "builtin:docx" - blocks, err := parseDocx(content) - if err != nil { + blocks, revised, err := parseDocxRevised(content) + switch { + case err != nil: doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error()) doc.Meta.Confidence = 0.3 - } else { + case revised: + doc.Blocks = blocks + doc.Warnings = append(doc.Warnings, docxRevisionWarning) + doc.Meta.Confidence = 0.85 + default: doc.Blocks = blocks doc.Meta.Confidence = 0.92 } @@ -161,22 +166,73 @@ func parsePlain(s string) []core.DocBlock { // ---- docx(OOXML:zip 内 word/document.xml)---- +// xpar 是解码后的段落。文字按文档顺序从任意深度的 w:t 收集,w:ins、 +// w:hyperlink、w:fldSimple、w:sdt 等包裹层内的文字因此不会丢;被删除或 +// 移走的原文(w:del、w:moveFrom)、域代码(w:instrText)与兼容回退分支 +// (mc:Fallback,与 mc:Choice 内容重复)不进正文。 type xpar struct { - Props struct { - Style struct { - Val string `xml:"val,attr"` - } `xml:"pStyle"` - } `xml:"pPr"` - Runs []xrun `xml:"r"` + Style string + Text string + Revised bool // 段内含修订标记(插入/删除/移动) } -type xrun struct { - Text []struct { - Content string `xml:",chardata"` - } `xml:"t"` - Break struct { - Val string `xml:"val,attr"` - } `xml:"br"` +// docxSkipText 子树里的文字不属于当前正文。 +var docxSkipText = map[string]bool{ + "del": true, "moveFrom": true, "instrText": true, "delInstrText": true, "Fallback": true, +} + +// docxRevision 出现即说明文档带修订。 +var docxRevision = map[string]bool{"ins": true, "del": true, "moveFrom": true, "moveTo": true} + +// UnmarshalXML 按 token 流遍历段落子树。按固定层级(p/r/t)映射只认 w:p +// 的直接子级 w:r,修订插入、超链接与域结果里的文字会被静默跳过。 +func (p *xpar) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var sb strings.Builder + skip := 0 + for { + tok, err := d.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + name := t.Name.Local + if docxRevision[name] { + p.Revised = true + } + switch { + case docxSkipText[name]: + skip++ + case skip > 0: + case name == "pStyle" && p.Style == "": + p.Style = attrAnyNS(t, "val") + case name == "t": + var s string + if err := d.DecodeElement(&s, &t); err != nil { + return err + } + sb.WriteString(s) + } + case xml.EndElement: + if docxSkipText[t.Name.Local] && skip > 0 { + skip-- + } + if t.Name == start.Name { + p.Text = sb.String() + return nil + } + } + } +} + +// attrAnyNS 按本地名取属性值(w:val 与无前缀 val 都算)。 +func attrAnyNS(st xml.StartElement, name string) string { + for _, a := range st.Attr { + if a.Name.Local == name { + return a.Value + } + } + return "" } type xtbl struct { @@ -192,86 +248,108 @@ type xtc struct { } func parseDocx(content []byte) ([]core.DocBlock, error) { + blocks, _, err := parseDocxRevised(content) + return blocks, err +} + +// parseDocxRevised 同 parseDocx,另报告文档是否带修订标记。 +func parseDocxRevised(content []byte) ([]core.DocBlock, bool, error) { zr, err := openZip(content) if err != nil { - return nil, fmt.Errorf("open zip: %w", err) + return nil, false, fmt.Errorf("open zip: %w", err) } docXML, err := zipPart(zr, "word/document.xml") if err != nil { - return nil, err + return nil, false, err } return parseDocxBody(docXML) } +// docxRevisionWarning 文档带修订时的显式告警(SR-A5:不静默)。 +const docxRevisionWarning = "docx 含修订标记:正文按「接受全部修订」提取(插入保留、删除不计入);审阅修订过程请查看原件" + // parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地 // DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。 -func parseDocxBody(docXML []byte) ([]core.DocBlock, error) { +// 段落可能包在 w:ins、w:sdt 等容器里,所以任意深度的 p 与 tbl 都识别; +// 返回值 revised 报告是否遇到修订标记。 +func parseDocxBody(docXML []byte) (blocks []core.DocBlock, revised bool, err error) { dec := xml.NewDecoder(bytes.NewReader(docXML)) - var blocks []core.DocBlock + skip := 0 for { tok, err := dec.Token() if err == io.EOF { break } if err != nil { - return nil, fmt.Errorf("decode document.xml: %w", err) + return nil, revised, fmt.Errorf("decode document.xml: %w", err) + } + if end, ok := tok.(xml.EndElement); ok { + if docxSkipText[end.Name.Local] && skip > 0 { + skip-- + } + continue } start, ok := tok.(xml.StartElement) if !ok { continue } - switch start.Name.Local { + name := start.Name.Local + if docxRevision[name] { + revised = true + } + if docxSkipText[name] { + skip++ + continue + } + if skip > 0 { + continue + } + switch name { case "p": var p xpar if err := dec.DecodeElement(&p, &start); err != nil { - return nil, fmt.Errorf("decode paragraph: %w", err) + return nil, revised, fmt.Errorf("decode paragraph: %w", err) } - text := paragraphText(p) - if strings.TrimSpace(text) == "" { + revised = revised || p.Revised + if strings.TrimSpace(p.Text) == "" { continue } - if lvl, ok := headingLevel(p.Props.Style.Val); ok { - blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text}) + if lvl, ok := headingLevel(p.Style); ok { + blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: p.Text}) } else { - blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text}) + blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: p.Text}) } case "tbl": var tbl xtbl if err := dec.DecodeElement(&tbl, &start); err != nil { - return nil, fmt.Errorf("decode table: %w", err) + return nil, revised, fmt.Errorf("decode table: %w", err) } - if rows := tableRows(tbl); len(rows) > 0 { + rows, tblRevised := tableRows(tbl) + revised = revised || tblRevised + if len(rows) > 0 { blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows}) } } } - return blocks, nil + return blocks, revised, nil } -func tableRows(tbl xtbl) [][]string { +func tableRows(tbl xtbl) ([][]string, bool) { var rows [][]string + revised := false for _, tr := range tbl.Rows { row := []string{} for _, tc := range tr.Cells { cell := []string{} for _, p := range tc.Paragraphs { - cell = append(cell, paragraphText(p)) + revised = revised || p.Revised + cell = append(cell, p.Text) } row = append(row, strings.Join(cell, " ")) } rows = append(rows, row) } - return rows -} - -func paragraphText(p xpar) string { - var sb strings.Builder - for _, r := range p.Runs { - for _, t := range r.Text { - sb.WriteString(t.Content) - } - } - return sb.String() + return rows, revised } func headingLevel(style string) (int, bool) { diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 52ee616..7bf0c42 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -112,6 +112,68 @@ func TestParseDocx(t *testing.T) { } } +// docxWith 把 body 片段包成最小 docx(zip 内仅 word/document.xml)。 +func docxWith(t *testing.T, body string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + f, err := zw.Create("word/document.xml") + if err != nil { + t.Fatal(err) + } + f.Write([]byte(`` + body + ``)) + zw.Close() + return buf.Bytes() +} + +// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文; +// 含修订时显式告警(SR-A5:禁止静默降级)。 +func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) { + body := `周期18` + + `12个月` + + `见附件A` + + `3页` + + `新增项` + doc := Parse("修订稿.docx", "", docxWith(t, body)) + var paras []string + var cell string + for _, b := range doc.Blocks { + switch b.Type { + case core.BlockPara: + paras = append(paras, b.Text) + case core.BlockTable: + if len(b.Rows) > 0 && len(b.Rows[0]) > 0 { + cell = b.Rows[0][0] + } + } + } + want := []string{"周期18个月", "见附件A", "3页"} + if strings.Join(paras, "|") != strings.Join(want, "|") { + t.Errorf("paragraphs: got %q want %q", paras, want) + } + if cell != "新增项" { + t.Errorf("table cell inside w:ins: got %q", cell) + } + if strings.Contains(doc.PlainText(), "12个月") { + t.Errorf("deleted text leaked into body: %q", doc.PlainText()) + } + warned := false + for _, w := range doc.Warnings { + warned = warned || strings.Contains(w, "修订") + } + if !warned { + t.Errorf("tracked changes must be reported in warnings, got %v", doc.Warnings) + } +} + +// 无修订的普通文档不产生修订告警(避免噪音)。 +func TestParseDocxNoRevisionNoWarning(t *testing.T) { + doc := Parse("干净.docx", "", docxWith(t, `正文`)) + if len(doc.Warnings) != 0 { + t.Errorf("clean docx warnings: %v", doc.Warnings) + } +} + func TestParsePDFInvalid(t *testing.T) { doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf")) if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 { diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d8279f0..3593b83 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -352,7 +352,7 @@ func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nrun } func (c *runCtx) Tools() []core.ToolDesc { - list := c.mgr.registry.List() + list := tools.FilterExecDisabled(c.mgr.registry.List()) out := make([]core.ToolDesc, 0, len(list)) fs := c.eff.GetBool("runtime.fsTools", false) code := c.eff.GetBool("runtime.codeOrchestration", false) diff --git a/internal/server/server.go b/internal/server/server.go index 40a0e95..8a5facb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1241,7 +1241,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { // visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。 func (s *Server) visibleTools(owner string) []core.ToolDesc { - list := s.reg.List() + list := tools.FilterExecDisabled(s.reg.List()) if s.set.Effective(owner, "").GetBool("runtime.fsTools", false) { return list } diff --git a/internal/tools/exec.go b/internal/tools/exec.go index a27e3b6..5a42cf0 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" core "agent-system-core" ) @@ -26,8 +27,42 @@ const ( execSetting = "runtime.exec" ) -// execGate runtime.exec 总闸(默认开;false = 执行原语整体禁用,fail-closed)。 +// execHardOff 启动级硬关闭(组合根依启动参数设置)。runtime.exec 是用户可写的 +// 业务设置,多用户部署时任何人都能把它改回 true;硬关闭不读设置,设置无法覆盖。 +var execHardOff atomic.Bool + +// SetExecDisabled 设置启动级硬关闭;仅组合根在装配期调用。 +func SetExecDisabled(off bool) { execHardOff.Store(off) } + +// ExecDisabled 报告是否处于启动级硬关闭。 +func ExecDisabled() bool { return execHardOff.Load() } + +// execToolNames 受硬关闭约束的执行原语。 +var execToolNames = map[string]bool{"run_shell": true, "run_python": true, "register_artifact": true} + +// IsExecTool 报告工具是否属于执行原语族。 +func IsExecTool(name string) bool { return execToolNames[name] } + +// FilterExecDisabled 硬关闭时从工具清单移除执行原语(可见性一侧;执行一侧由 execGate 拒绝)。 +func FilterExecDisabled(list []core.ToolDesc) []core.ToolDesc { + if !ExecDisabled() { + return list + } + out := make([]core.ToolDesc, 0, len(list)) + for _, t := range list { + if !IsExecTool(t.Name) { + out = append(out, t) + } + } + return out +} + +// execGate 执行原语总闸:启动级硬关闭优先;其后 runtime.exec(默认开;false = +// 执行原语整体禁用,fail-closed)。 func execGate(rc ToolExecCtx) error { + if ExecDisabled() { + return fmt.Errorf("exec tools: 已被启动参数禁用(disableExecTools)") + } if !rc.SettingBool(execSetting, true) { return fmt.Errorf("exec tools: 已被设置禁用(%s=false)", execSetting) } diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 7dc2f9e..c29cbbd 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -321,8 +321,11 @@ func firstPos(vals ...int) int { // InterpreterExecConfig 把产品解释器白名单换算为 agent-native 内核的 // ExecConfig(squad 角色经 agent-native 后端执行时复用同一套约束与环境)。 -// 两个解释器都缺席时返回 nil(内核侧不注册执行工具,fail-closed)。 +// 两个解释器都缺席或启动级硬关闭时返回 nil(内核侧不注册执行工具,fail-closed)。 func InterpreterExecConfig() *agenttools.ExecConfig { + if ExecDisabled() { + return nil + } cfg := &agenttools.ExecConfig{Env: execEnv()} if p, err := exec.LookPath("bash"); err == nil { cfg.Bash = p diff --git a/internal/tools/exec_interpreter_test.go b/internal/tools/exec_interpreter_test.go index 73f5025..41d28ee 100644 --- a/internal/tools/exec_interpreter_test.go +++ b/internal/tools/exec_interpreter_test.go @@ -116,6 +116,43 @@ func TestRunShellEmptyAndGate(t *testing.T) { } } +// 启动级硬关闭:用户设置 runtime.exec=true 也无法打开执行原语, +// 且执行原语从模型可见清单中消失、内核执行配置为空(fail-closed)。 +func TestExecHardDisabledOverridesSettings(t *testing.T) { + t.Cleanup(func() { SetExecDisabled(false) }) + SetExecDisabled(true) + r := newInterpRegistry(t) + if err := RegisterExecTools(r); err != nil { + t.Fatal(err) + } + rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true} + for _, name := range []string{"run_shell", "run_python"} { + res := interpCall(t, r, rc, name, `{"script":"echo should-not-run"}`) + if res.Status != "error" || !strings.Contains(res.Output, "启动参数禁用") { + t.Fatalf("%s must be refused under hard disable: %s %s", name, res.Status, res.Output) + } + } + res := interpCall(t, r, rc, "register_artifact", `{"path":"x.docx"}`) + if res.Status != "error" || !strings.Contains(res.Output, "启动参数禁用") { + t.Fatalf("register_artifact must be refused under hard disable: %s %s", res.Status, res.Output) + } + if InterpreterExecConfig() != nil { + t.Fatal("kernel exec config must be nil under hard disable") + } + for _, d := range FilterExecDisabled(r.List()) { + if IsExecTool(d.Name) { + t.Fatalf("exec tool %s must be hidden under hard disable", d.Name) + } + } + SetExecDisabled(false) + if res := interpCall(t, r, rc, "run_shell", `{"script":"echo back"}`); res.Status != "ok" { + t.Fatalf("exec must work again when not hard-disabled: %s %s", res.Status, res.Output) + } + if len(FilterExecDisabled(r.List())) != len(r.List()) { + t.Fatal("filter must be a no-op when not hard-disabled") + } +} + func TestRunShellStreamsOutput(t *testing.T) { r := newInterpRegistry(t) rc := &execFake{root: t.TempDir(), owner: "o1", conv: "c1", execOn: true} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index ce94ed7..c77715b 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -18,7 +18,8 @@ import ( // 进项目公共流)。 // 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID // 重建受限宿主句柄,供下游恢复路径)。 -const SurfaceVersion = "2.4.0-experimental" +// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。 +const SurfaceVersion = "2.5.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -63,6 +64,10 @@ type Options struct { // 零值=false=现状全量镜像。true 时会话卡只进本人会话流,不发布即不持久, // 项目流重放不含会话卡;项目级卡不受影响)。 DisableConvCardMirror bool + // DisableExecTools 启动级硬关闭执行原语(run_shell / run_python / + // register_artifact 及小队内核执行配置):用户设置 runtime.exec 无法覆盖。 + // 零值=false=现状。多用户共享部署在执行隔离就位前应置 true。 + DisableExecTools bool } var ( diff --git a/pkg/extension/link/link.go b/pkg/extension/link/link.go index fac95f3..54f1c3e 100644 --- a/pkg/extension/link/link.go +++ b/pkg/extension/link/link.go @@ -13,7 +13,8 @@ func init() { Addr: opt.Addr, DataDir: opt.DataDir, PluginsDir: opt.PluginsDir, ForceMock: opt.Mock, StorageDSN: opt.StorageDSN, ArtifactsDir: opt.ArtifactsDir, AuthMode: opt.AuthMode, DisableUploadIndexing: opt.DisableUploadIndexing, - DisableConvCardMirror: opt.DisableConvCardMirror, + DisableConvCardMirror: opt.DisableConvCardMirror, + DisableExecTools: opt.DisableExecTools, }) }) } -- 2.54.0