Files
Agent-System-Backend/internal/tools/exec.go
weichen 6c01d00a3f Close remaining Task 10 renderer review findings
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
2026-10-15 22:33:29 +00:00

172 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 执行原语工具族(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"
}
}