Why: Direct artifact registration trusted filename extensions, the shared renderer parity test used a reduced fixture, and migration comparison omitted canonical list/task/rule/alignment properties plus their Word presentation. Evidence: go test -count=1 ./...; go test -race ./internal/docgen ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; PYTHONPATH=. python3 -m unittest tests.test_housestyle; git diff --check. Impact: Reject renamed WordprocessingML packages while preserving non-Word artifacts, pin the reviewed renderer semantics, and report canonical and DOCX presentation regressions as accidental differences. Refs: Task 10; /tmp/task10-backend-final-review-last.md
172 lines
6.3 KiB
Go
172 lines
6.3 KiB
Go
// 执行原语工具族(agent-native 交付,2026-10-01 用户定调):文档/表格/演示稿
|
||
// 等产物生成是 Agent 的能力。系统提供执行原语、登记桥,以及经过安全验证的
|
||
// render_document 统一 Word 入口;不提供可绕过 renderer 的 write_docx 工具。
|
||
//
|
||
// 信任模型:本地单用户工作台(单二进制自托管)。run_shell/run_python 的脚本
|
||
// 一律先落盘为会话工作区内的文件再交给白名单解释器(bash/python3)执行,
|
||
// 工作目录锁定会话沙箱,超时与输出上限硬性截断;画像三清单与 runtime.exec
|
||
// 开关可禁用(fail-closed)。脚本落盘保留审计痕迹。
|
||
package tools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"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"
|
||
)
|
||
|
||
var errDirectWordRegistration = errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
|
||
|
||
// 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 := checkToolExternalEffect(rc); 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: "把会话工作区内已生成的文件登记为交付物工件(xlsx/pptx/pdf/图片/数据及迁移对照文件):登记后可预览、下载。普通 Word 使用 render_document,项目或受控 Word 交给 RDM;二进制文件不能用 fs_write_file。",
|
||
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)
|
||
}
|
||
if isWordBinaryName(abs) {
|
||
return "", errDirectWordRegistration
|
||
}
|
||
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
|
||
}
|
||
if isWordprocessingMLPackage(b) {
|
||
return "", errDirectWordRegistration
|
||
}
|
||
if err := ooxmlStructuralCheck(filepath.Base(abs), b); err != nil {
|
||
return "", fmt.Errorf("register_artifact: %s 结构自检未通过:%v。请用 housestyle kit 重建(见 $AGENT_SKILLS_DIR/house-style/SKILL.md),不要用 zipfile 手工拼 OOXML", a.Path, err)
|
||
}
|
||
name := filepath.Base(abs)
|
||
mime := mimeOfExt(name)
|
||
if err := checkToolExternalEffect(rc); err != nil {
|
||
return "", err
|
||
}
|
||
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"
|
||
}
|
||
}
|