ci / vet-and-test (pull_request) Successful in 43s
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)
154 lines
5.6 KiB
Go
154 lines
5.6 KiB
Go
// 执行原语工具族(agent-native 交付,2026-10-01 用户定调):文档/表格/演示稿
|
||
// 等产物生成是 Agent 的能力——系统只提供原语与登记桥,生成方法沉淀在提示词库
|
||
// (base.deliverables 配方),不提供 write_docx 这类领域工具。
|
||
//
|
||
// 信任模型:本地单用户工作台(单二进制自托管)。run_shell/run_python 的脚本
|
||
// 一律先落盘为会话工作区内的文件再交给白名单解释器(bash/python3)执行,
|
||
// 工作目录锁定会话沙箱,超时与输出上限硬性截断;画像三清单与 runtime.exec
|
||
// 开关可禁用(fail-closed)。脚本落盘保留审计痕迹。
|
||
package tools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync/atomic"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
const (
|
||
execMaxOutput = 64 << 10 // 单次输出上限 64KB(头尾保留)
|
||
execDefaultTimeoutS = 600 // 未指定 timeout_sec 时的默认超时;模型可自行设更长,没有硬上限
|
||
execMaxArtifactBytes = 10 << 20 // register_artifact 登记上限 10MB
|
||
execScriptDir = ".agent" // 脚本落位(会话工作区内,可审计)
|
||
execSetting = "runtime.exec"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// execSandbox 返回(并确保存在)会话沙箱目录。
|
||
func execSandbox(rc ToolExecCtx) (string, error) {
|
||
sandbox, err := fsSandbox(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := os.MkdirAll(sandbox, 0o755); err != nil {
|
||
return "", fmt.Errorf("exec tools: create sandbox: %w", err)
|
||
}
|
||
return sandbox, nil
|
||
}
|
||
|
||
// RegisterExecTools 注册执行原语族的登记桥(register_artifact)。
|
||
// run_shell / run_python 在 exec_interpreter.go,由 RegisterInterpreterTools 同批注册
|
||
// (契约见 base.deliverables 提示词)。
|
||
func RegisterExecTools(r *Registry) error {
|
||
return r.Register(core.ToolDesc{
|
||
Name: "register_artifact", Source: core.SourceBuiltin,
|
||
Description: "把会话工作区内已生成的文件登记为交付物工件(docx/xlsx/pptx/pdf/图片/数据等):登记后文件出现在对话交付卡片与工件面板,可预览、下载。二进制文件不能用 fs_write_file(只收文本)——先由 run_python/run_shell 生成到工作区,再用本工具登记。",
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||
`"path":{"type":"string","description":"工作区内相对路径(如 报告.docx)"}}` +
|
||
`,"required":["path"]}`),
|
||
}, execRegisterArtifact)
|
||
}
|
||
|
||
func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
if err := execGate(rc); err != nil {
|
||
return "", err
|
||
}
|
||
var a struct {
|
||
Path string `json:"path"`
|
||
}
|
||
if len(args) > 0 {
|
||
if err := json.Unmarshal(args, &a); err != nil {
|
||
return "", fmt.Errorf("register_artifact: bad args: %w", err)
|
||
}
|
||
}
|
||
if strings.TrimSpace(a.Path) == "" {
|
||
return "", fmt.Errorf("register_artifact: path required")
|
||
}
|
||
sandbox, err := execSandbox(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
abs, err := fsResolve(sandbox, a.Path)
|
||
if err != nil {
|
||
return "", fmt.Errorf("register_artifact: %w", err)
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil {
|
||
return "", fmt.Errorf("register_artifact: %s: %v", a.Path, err)
|
||
}
|
||
if st.IsDir() {
|
||
return "", fmt.Errorf("register_artifact: %s 是目录(逐个文件登记)", a.Path)
|
||
}
|
||
if st.Size() > execMaxArtifactBytes {
|
||
return "", fmt.Errorf("register_artifact: 文件超过 10MB 上限(%d 字节)", st.Size())
|
||
}
|
||
b, err := os.ReadFile(abs)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
name := filepath.Base(abs)
|
||
mime := mimeOfExt(name)
|
||
art, err := rc.WriteArtifact(name, artifactKindOf(name, mime), mime, b)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("已登记交付物 %s(%s,%d 字节)——对话内会出现可预览的文件卡片", art.Name, art.ID, art.Size), nil
|
||
}
|
||
|
||
// artifactKindOf 按扩展名/mime 归类工件 kind(工件面板过滤与卡片图标用)。
|
||
func artifactKindOf(name, mime string) string {
|
||
switch {
|
||
case strings.HasPrefix(mime, "image/"):
|
||
return "image"
|
||
case strings.Contains(mime, "json") || strings.Contains(mime, "csv"):
|
||
return "data"
|
||
default:
|
||
return "document"
|
||
}
|
||
}
|