Files
Agent-System-Backend/internal/tools/exec_interpreter.go
T
weichenandCursor b85a1e1ca3 Add immutable layered Python environments for the sandbox.
Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox.
Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run.
Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables.
Refs: .task/feature-layered-python-env.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 13:52:48 +00:00

445 lines
14 KiB
Go
Raw 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.
// run_shell / run_python 解释器执行器(与 register_artifact 同族)。
//
// 语义:模型提交的脚本原样落盘到会话工作区 .agent/ 下(字节精确、执行后保留以便审计),
// 再以该文件为唯一参数启动白名单解释器(bash / python3 的绝对路径)——模型输入不进入
// 进程参数。工作目录锁定会话沙箱;超时取请求 context 与 timeout_sec(默认 600s,无硬上限)
// 的先到者,超时杀掉整个进程组;输出 64KB 头尾截断。执行中的输出经可选回调流出。
// 非零退出、超时与取消作为文本反馈返回
// (模型据此自纠),仅启动失败算工具错误。
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
core "agent-system-core"
agenttools "agent-native/runtime/tools"
)
// RegisterInterpreterTools 注册解释器执行原语(run_shell / run_python)。
func RegisterInterpreterTools(r *Registry) error {
if err := r.Register(core.ToolDesc{
Name: "run_shell", Source: core.SourceBuiltin, Risk: "medium",
Description: "在会话工作区目录内执行 bash 脚本:检查环境、处理文件、调用 python3 -m housestyle.build。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。沙箱断网且不继承宿主家目录,不要用 pip/apt 安装;缺的 Python 包用 python_env 向系统申请。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"bash 脚本(可多行)"},` +
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
`,"required":["script"]}`),
}, execShell); err != nil {
return err
}
if err := registerPythonEnvTool(r); err != nil {
return err
}
return r.Register(core.ToolDesc{
Name: "run_python", Source: core.SourceBuiltin, Risk: "medium",
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层已提供 python-docx、openpyxl、python-pptx 等,可直接 import。缺的包用 python_env 申请,不要 pip install。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
`,"required":["script"]}`),
}, execPython)
}
// skillsRoot 由组合根在启动时注入(<data>/skills)。空 = 不注入技能路径。
var skillsRoot string
// SetSkillsRoot 让 run_python / run_shell 能 import housestyle,并看到技能目录。
func SetSkillsRoot(dir string) { skillsRoot = dir }
// execDepsDir 是 sandbox 档的只读基础依赖层(site-packages);组合根启动时注入。
var execDepsDir string
// SetExecDepsDir 配置 sandbox 档基础依赖层目录(空 = 不挂载,现状)。
func SetExecDepsDir(dir string) { execDepsDir = dir }
// 托管解释器与 pip 垫片。空表示尚未配置,python3 回落到 /usr/bin/python3,
// 不再使用 PATH 里可能指向 /home 的解释器(沙箱会把 /home 遮掉)。
var (
execPythonBin string
execPythonPrefix string
execShimDir string
execLayers func(conversationID string) (extra []string, isolated string)
)
// SetExecPython 配置托管解释器。prefix 是需要在沙箱里重新挂载的目录。
func SetExecPython(bin, prefix string) {
execPythonBin = bin
execPythonPrefix = prefix
}
// SetExecShimDir 把 pip 垫片目录放到 PATH 最前。
func SetExecShimDir(dir string) { execShimDir = dir }
// SetExecLayers 按会话返回扩展层或隔离层的 site-packages 目录。
func SetExecLayers(fn func(conversationID string) (extra []string, isolated string)) {
execLayers = fn
}
// execEnv 最小环境。PATH 固定,不继承宿主 PATH:宿主 PATH 里的 linuxbrew
// 位于 /home 下,沙箱 tmpfs 会把它遮掉,解析到那里的 python3 无法启动。
func execEnv() []string { return execEnvFor("") }
func execEnvFor(conv string) []string {
var out []string
for _, kv := range os.Environ() {
for _, keep := range []string{"HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
if strings.HasPrefix(kv, keep) {
out = append(out, kv)
break
}
}
}
out = append(out,
"PATH="+execPATH(),
"PYTHONDONTWRITEBYTECODE=1",
"PIP_NO_INDEX=1",
)
dirs, _ := pythonPathFor(conv)
if skillsRoot != "" {
out = append(out, "AGENT_SKILLS_DIR="+skillsRoot)
dirs = append(dirs, filepath.Join(skillsRoot, "house-style"))
}
if len(dirs) > 0 {
out = append(out, "PYTHONPATH="+strings.Join(dirs, string(os.PathListSeparator)))
}
return out
}
func execPATH() string {
var parts []string
if execPythonBin != "" {
parts = append(parts, filepath.Dir(execPythonBin))
}
if execShimDir != "" {
parts = append(parts, execShimDir)
}
parts = append(parts, "/usr/local/bin", "/usr/bin", "/bin")
return strings.Join(parts, ":")
}
// pythonPathFor 返回要放进 PYTHONPATH、并且在沙箱里只读挂载的目录。
// 隔离层替换基础层,不与基础层叠放。
func pythonPathFor(conv string) (dirs []string, isolated bool) {
var extra []string
var iso string
if execLayers != nil && conv != "" {
extra, iso = execLayers(conv)
}
if iso != "" {
return []string{iso}, true
}
if execDepsDir != "" {
dirs = append(dirs, execDepsDir)
}
dirs = append(dirs, extra...)
return dirs, false
}
// truncateOutput 头 2/3 + 尾 1/3 保留,中部标记省略。
func truncateOutput(b []byte) string {
if len(b) <= execMaxOutput {
return string(b)
}
head := execMaxOutput * 2 / 3
tail := execMaxOutput / 3
return string(b[:head]) + fmt.Sprintf("\n…(输出超限,省略 %d 字节)…\n", len(b)-head-tail) + string(b[len(b)-tail:])
}
// writeScript 把模型脚本落盘到工作区 .agent/ 下(字节精确,执行后保留),返回绝对路径。
func writeScript(rc ToolExecCtx, sandbox, prefix, ext, content string) (string, error) {
dir := filepath.Join(sandbox, execScriptDir)
if err := checkToolExternalEffect(rc); err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
p := filepath.Join(dir, fmt.Sprintf("%s-%s-%d%s", prefix, rc.ConversationID(), time.Now().UnixNano(), ext))
if err := checkToolExternalEffect(rc); err != nil {
return "", err
}
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
return "", err
}
return p, nil
}
type execContexter interface {
Context() context.Context
}
func execParentCtx(rc ToolExecCtx) context.Context {
if c, ok := rc.(execContexter); ok {
if ctx := c.Context(); ctx != nil {
return ctx
}
}
return context.Background()
}
// resolveInterpreter 把白名单解释器名钉成绝对路径。
// python3 优先用托管解释器,其次 /usr/bin/python3。不用 LookPath:
// 服务进程的 PATH 常把 /home 下的 linuxbrew 放在前面,那个路径在沙箱里不存在。
func resolveInterpreter(name string) (string, error) {
switch name {
case "bash":
for _, p := range []string{"/bin/bash", "/usr/bin/bash"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, nil
}
}
case "python3":
if execPythonBin != "" {
if st, err := os.Stat(execPythonBin); err == nil && !st.IsDir() {
return execPythonBin, nil
}
return "", fmt.Errorf("python3: configured interpreter missing: %s", execPythonBin)
}
if st, err := os.Stat("/usr/bin/python3"); err == nil && !st.IsDir() {
return "/usr/bin/python3", nil
}
default:
return "", fmt.Errorf("interpreter %s is not allowlisted", name)
}
p, err := exec.LookPath(name)
if err != nil {
return "", err
}
if !filepath.IsAbs(p) {
return "", fmt.Errorf("%s: resolved path is not absolute: %s", name, p)
}
if strings.HasPrefix(p, "/home/") || strings.HasPrefix(p, "/root/") {
return "", fmt.Errorf("%s: refusing %s because the sandbox hides that path", name, p)
}
return p, nil
}
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
// 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
func runInterpreter(parent context.Context, interpreter, scriptPath, dir, convID string, timeoutS int, sink func(stream, text string), checks ...func() error) (string, error) {
if parent == nil {
parent = context.Background()
}
if timeoutS <= 0 {
timeoutS = execDefaultTimeoutS
}
bin, err := resolveInterpreter(interpreter)
if err != nil {
return "", fmt.Errorf("%s: %w", interpreter, err)
}
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second)
defer cancel()
var check func() error
if len(checks) > 0 {
check = checks[0]
}
spec := ExecSpec{
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnvFor(convID),
SkillsDir: skillsRoot, DepsDir: execDepsDir, Timeout: time.Duration(timeoutS) * time.Second,
PythonPrefix: execPythonPrefix, ShimDir: execShimDir,
CheckExternalEffect: check,
}
if dirs, isolated := pythonPathFor(convID); isolated {
spec.DepsDir = ""
spec.LayerDirs = dirs
} else if len(dirs) > 1 || (len(dirs) == 1 && dirs[0] != execDepsDir) {
spec.LayerDirs = dirs
spec.DepsDir = ""
}
pr, pw := io.Pipe()
var buf bytes.Buffer
readDone := make(chan struct{})
go func() {
defer close(readDone)
tmp := make([]byte, 4096)
for {
n, rerr := pr.Read(tmp)
if n > 0 {
if buf.Len() < 1<<20 {
buf.Write(tmp[:n])
}
if sink != nil {
sink("stdout", string(tmp[:n]))
}
}
if rerr != nil {
return
}
}
}()
proc, err := currentExecBackend().Start(ctx, spec, pw)
if err != nil {
_ = pw.Close()
<-readDone
return "", fmt.Errorf("%s: %w", interpreter, err)
}
err = proc.Wait()
_ = pw.Close()
<-readDone
text := truncateOutput(buf.Bytes())
// S0 工作区 overlay:回收摘要(写回/丢弃/冲突)追加到工具输出——
// 模型需要知道改动是否落地、冲突文件在哪。
if ws, ok := proc.(interface{ Summary() string }); ok {
if s := ws.Summary(); s != "" {
text += s
}
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Sprintf("exit=timeout(%ds)\n%s", timeoutS, text), nil
}
if errors.Is(parent.Err(), context.Canceled) {
return fmt.Sprintf("exit=canceled\n%s", text), nil
}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return fmt.Sprintf("exit=%d\n%s", ee.ExitCode(), text), nil
}
return "", fmt.Errorf("%s: %v:%s", interpreter, err, text)
}
return "exit=0\n" + text, nil
}
func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) {
if err := execGate(rc); err != nil {
return "", err
}
var a struct {
Script string `json:"script"`
Command string `json:"command"`
TimeoutS int `json:"timeout_sec"`
TimeoutSec int `json:"timeoutSec"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("run_shell: bad args: %w", err)
}
}
script := a.Script
if strings.TrimSpace(script) == "" {
script = a.Command
}
if strings.TrimSpace(script) == "" {
return "", fmt.Errorf("run_shell: script required")
}
sandbox, err := execSandbox(rc)
if err != nil {
return "", err
}
p, err := writeScript(rc, sandbox, "sh", ".sh", script)
if err != nil {
return "", err
}
return runInterpreter(execParentCtx(rc), "bash", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) })
}
// ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。
// 调用方在 Start 之前设置 ExtraFiles、Stdout 和 Stderr。取消 ctx 会杀掉整个进程组。
func ScriptCommand(ctx context.Context, sandbox, path string) (*exec.Cmd, error) {
if ctx == nil {
ctx = context.Background()
}
bin, err := resolveInterpreter("python3")
if err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, bin, path)
cmd.Dir = sandbox
cmd.Env = execEnvFor("")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
return cmd, nil
}
func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) {
if err := execGate(rc); err != nil {
return "", err
}
var a struct {
Script string `json:"script"`
TimeoutS int `json:"timeout_sec"`
TimeoutSec int `json:"timeoutSec"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("run_python: bad args: %w", err)
}
}
if strings.TrimSpace(a.Script) == "" {
return "", fmt.Errorf("run_python: script required")
}
sandbox, err := execSandbox(rc)
if err != nil {
return "", err
}
p, err := writeScript(rc, sandbox, "py", ".py", a.Script)
if err != nil {
return "", err
}
return runInterpreter(execParentCtx(rc), "python3", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) })
}
// toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。
func toolOutputSink(rc ToolExecCtx) func(stream, text string) {
type emitter interface {
EmitToolOutput(stream, text string)
}
e, ok := rc.(emitter)
if !ok {
return nil
}
return e.EmitToolOutput
}
func firstPos(vals ...int) int {
for _, v := range vals {
if v > 0 {
return v
}
}
return 0
}
// InterpreterExecConfig 把产品解释器白名单换算为 agent-native 内核的
// ExecConfig(squad 角色经 agent-native 后端执行时复用同一套约束与环境)。
// 两个解释器都缺席或启动级硬关闭时返回 nil(内核侧不注册执行工具,fail-closed)。
func InterpreterExecConfig() *agenttools.ExecConfig {
if ExecDisabled() {
return nil
}
cfg := &agenttools.ExecConfig{Env: execEnvFor("")}
if p, err := exec.LookPath("bash"); err == nil {
cfg.Bash = p
}
if p, err := exec.LookPath("python3"); err == nil {
cfg.Python = p
}
if cfg.Bash == "" && cfg.Python == "" {
return nil
}
return cfg
}