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,
})
})
}