Files
Agent-Native/runtime/tools/exec.go
T
weichen afbb567301 Bind session scope identity, enforce write sets, repair torn JSONL
Why: F02/F04/F15 - contract requests shared one transcript across
session scopes, Writable was dropped, and a torn JSONL tail poisoned
the next append; pure-text contract results lost their text.
Evidence: adapters/native, runtime/session, runtime/tools and
conformance tests pass (verify-u2.json); scope-isolation, write-set and
torn-tail regression cases added.
Impact: empty scope keeps legacy one-shot semantics; identity-less old
transcripts are never auto-adopted by a new scope.
Refs: feature/agent-stack-audit-remediation-b1 U2
2026-10-21 16:03:04 +00:00

313 lines
9.7 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unicode/utf8"
)
// ExecConfig 声明执行类工具的运行条件,由组合根显式提供;
// 未提供(nil)时 run_command / run_python 一律不注册(fail-closed)。
// 注意:工作区路径约束不等于 OS 沙箱——进程仍以当前用户权限运行,
// 隔离需由宿主环境提供(容器/沙箱),本文件只做时限、输出、env 与审计收敛。
type ExecConfig struct {
// Bash / Python 是解释器绝对路径;留空则对应工具不注册。
Bash string
Python string
// Env 传给子进程的完整白名单环境(KEY=VALUE);不含本进程任何隐式变量。
// nil 在注册/启动时归一为空白名单(不继承宿主环境);显式空切片同样为空白名单。
Env []string
// ScriptDir 落盘脚本目录(相对 root,默认 .agent/scripts);空用默认。
ScriptDir string
}
const (
execDefaultTimeout = 60 * time.Second
execMaxTimeout = 300 * time.Second
execMaxOutput = 64 << 10
)
// normalizeExecEnv maps a nil env to an empty whitelist baseline so os/exec
// does not inherit the host environment. An explicit empty or non-nil slice
// is returned unchanged.
func normalizeExecEnv(env []string) []string {
if env == nil {
return []string{}
}
return env
}
// blockedCommandMarkers 最小命令黑名单:明显提权/自毁模式直接拒绝。
// 这不是沙箱,只是防呆;真实隔离靠宿主环境。
var blockedCommandMarkers = []string{
"sudo ", "sudo\t", "sudo\n",
"rm -rf /", "rm -fr /",
":(){:|:&};:",
"mkfs", "shutdown", "reboot",
}
// RegisterExecTools 注册 run_command / run_python(解释器未配置的项跳过注册)。
func RegisterExecTools(r *Registry, root string, cfg ExecConfig) {
cfg.Env = normalizeExecEnv(cfg.Env)
if strings.TrimSpace(cfg.Bash) != "" {
r.Register(&runCommandTool{root: root, cfg: cfg})
}
if strings.TrimSpace(cfg.Python) != "" {
r.Register(&runPythonTool{root: root, cfg: cfg})
}
}
type runCommandTool struct {
root string
cfg ExecConfig
}
func (t *runCommandTool) Name() string { return "run_command" }
func (t *runCommandTool) Description() string {
return "Run a bash command in the workspace root (process group, default 60s timeout, 64 KiB output cap). Non-zero exit and timeout are reported as text. NOT an OS sandbox."
}
func (t *runCommandTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["command"],"properties":{"command":{"type":"string"},"timeout_seconds":{"type":"integer","description":"1..300, default 60"}}}`)
}
func (t *runCommandTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
var obj struct {
Command string `json:"command"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
if strings.TrimSpace(obj.Command) == "" {
return Result{Content: "command is empty", IsError: true}, nil
}
if blocked(obj.Command) {
return Result{Content: "command rejected by blocklist (sudo / destructive patterns are not allowed)", IsError: true}, nil
}
timeout := clampTimeout(obj.TimeoutSeconds)
out, total, timedOut, err := runProcess(ctx, t.cfg.Bash, []string{"-c", obj.Command}, t.root, t.cfg.Env, timeout)
content := formatProcessOutput(out, total, timedOut, err)
return Result{Content: content, IsError: timedOut || err != nil}, nil
}
type runPythonTool struct {
root string
cfg ExecConfig
}
func (t *runPythonTool) Name() string { return "run_python" }
func (t *runPythonTool) Description() string {
return "Run a Python script: the code is persisted under .agent/scripts/ in the workspace and executed with the configured python3 interpreter (default 60s timeout, 64 KiB output cap)."
}
func (t *runPythonTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["code"],"properties":{"code":{"type":"string"},"timeout_seconds":{"type":"integer","description":"1..300, default 60"}}}`)
}
func (t *runPythonTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
var obj struct {
Code string `json:"code"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
if strings.TrimSpace(obj.Code) == "" {
return Result{Content: "code is empty", IsError: true}, nil
}
dir := t.cfg.ScriptDir
if dir == "" {
dir = ".agent/scripts"
}
scriptDir := filepath.Join(t.root, filepath.FromSlash(dir))
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
name := fmt.Sprintf("%s-%06d.py", time.Now().UTC().Format("20060102T150405"), rand.Intn(1000000))
script := filepath.Join(scriptDir, name)
if err := os.WriteFile(script, []byte(obj.Code), 0o644); err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
timeout := clampTimeout(obj.TimeoutSeconds)
out, total, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
content := formatProcessOutput(out, total, timedOut, err)
content = "script: " + filepath.ToSlash(filepath.Join(dir, name)) + "\n" + content
return Result{Content: content, IsError: timedOut || err != nil}, nil
}
// runProcess 启动独立进程组的子进程;ctx 取消或超时先到者杀整组(SIGKILL)。
// 合并输出持续排入有界头尾缓冲,避免 CombinedOutput 无界增长;Wait 回收子进程。
func runProcess(ctx context.Context, interpreter string, argv []string, dir string, env []string, timeout time.Duration) (out []byte, total int, timedOut bool, err error) {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, interpreter, argv...)
cmd.Dir = dir
cmd.Env = normalizeExecEnv(env)
// 独立进程组:超时/取消时杀 -PGID,确保孙进程一并退出。
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 3 * time.Second
buf := newHeadTailBuffer(execMaxOutput)
cmd.Stdout = buf
cmd.Stderr = buf
err = cmd.Run()
out, total = buf.Snapshot()
timedOut = runCtx.Err() != nil && (ctx.Err() == nil || runCtx.Err() != ctx.Err())
return out, total, timedOut, err
}
func clampTimeout(seconds int) time.Duration {
if seconds <= 0 {
return execDefaultTimeout
}
if seconds > int(execMaxTimeout.Seconds()) {
return execMaxTimeout
}
return time.Duration(seconds) * time.Second
}
func blocked(command string) bool {
lower := strings.ToLower(command)
for _, m := range blockedCommandMarkers {
if strings.Contains(lower, m) {
return true
}
}
return false
}
// headTailBuffer keeps a fixed-size head and a ring-buffer tail while counting
// all bytes written, so chatty children cannot grow memory without bound.
type headTailBuffer struct {
mu sync.Mutex
max int
half int
head []byte
tail []byte
tailPos int
tailFilled int
total int
}
func newHeadTailBuffer(max int) *headTailBuffer {
if max < 2 {
max = 2
}
half := max / 2
return &headTailBuffer{
max: max,
half: half,
head: make([]byte, 0, half),
tail: make([]byte, half),
}
}
func (b *headTailBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
n := len(p)
for len(p) > 0 {
if len(b.head) < b.half {
take := b.half - len(b.head)
if take > len(p) {
take = len(p)
}
b.head = append(b.head, p[:take]...)
b.total += take
p = p[take:]
continue
}
// Head is full: remaining bytes rotate through the fixed tail ring.
for i := 0; i < len(p); i++ {
b.tail[b.tailPos] = p[i]
b.tailPos = (b.tailPos + 1) % b.half
if b.tailFilled < b.half {
b.tailFilled++
}
}
b.total += len(p)
break
}
return n, nil
}
// Snapshot returns retained bytes and the total byte count observed.
// When total <= max the slice is the full stream; when truncated it is
// head||tail of length max (callers use total for the dropped-bytes marker).
func (b *headTailBuffer) Snapshot() ([]byte, int) {
b.mu.Lock()
defer b.mu.Unlock()
if b.total <= b.half {
out := make([]byte, len(b.head))
copy(out, b.head)
return out, b.total
}
tail := b.tailBytesLocked()
out := make([]byte, 0, len(b.head)+len(tail))
out = append(out, b.head...)
out = append(out, tail...)
return out, b.total
}
func (b *headTailBuffer) tailBytesLocked() []byte {
if b.tailFilled == 0 {
return nil
}
if b.tailFilled < b.half {
out := make([]byte, b.tailFilled)
copy(out, b.tail[:b.tailFilled])
return out
}
out := make([]byte, b.half)
n := copy(out, b.tail[b.tailPos:])
copy(out[n:], b.tail[:b.tailPos])
return out
}
// formatProcessOutput 截断为头尾各半(总上限 execMaxOutput)并标注退出状态。
// out 为有界捕获结果;total 为子进程实际写出字节数(可大于 len(out))。
func formatProcessOutput(out []byte, total int, timedOut bool, err error) string {
text := string(out)
if total > execMaxOutput {
half := execMaxOutput / 2
dropped := total - execMaxOutput
head := CutUTF8(string(out), half)
tail := ""
if len(out) > half {
tail = string(out[len(out)-half:])
} else if len(out) > len(head) {
tail = string(out[len(head):])
}
for len(tail) > 0 && !utf8.RuneStart(tail[0]) {
tail = tail[1:]
}
text = head + fmt.Sprintf("\n[... truncated %d bytes ...]\n", dropped) + tail
}
switch {
case timedOut:
text += "\n[timeout: process group killed]"
case err != nil:
if ee, ok := err.(*exec.ExitError); ok {
text += fmt.Sprintf("\n[exit code %d]", ee.ExitCode())
} else {
text += "\n[error] " + err.Error()
}
default:
text += "\n[exit code 0]"
}
return text
}