Keep DOCX tracked-change/hyperlink text and add an exec hard switch #110
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
}
|
||||
|
||||
+21
-12
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+122
-44
@@ -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) {
|
||||
|
||||
@@ -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(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>` + body + `</w:body></w:document>`))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文;
|
||||
// 含修订时显式告警(SR-A5:禁止静默降级)。
|
||||
func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) {
|
||||
body := `<w:p><w:r><w:t>周期</w:t></w:r><w:ins w:id="1" w:author="a"><w:r><w:t>18</w:t></w:r></w:ins>` +
|
||||
`<w:del w:id="2" w:author="a"><w:r><w:delText>12</w:delText></w:r></w:del><w:r><w:t>个月</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>见</w:t></w:r><w:hyperlink r:id="rId5"><w:r><w:t>附件A</w:t></w:r></w:hyperlink></w:p>` +
|
||||
`<w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t>页</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tr><w:tc><w:p><w:ins w:id="3" w:author="a"><w:r><w:t>新增项</w:t></w:r></w:ins></w:p></w:tc></w:tr></w:tbl>`
|
||||
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, `<w:p><w:r><w:t>正文</w:t></w:r></w:p>`))
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+36
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user