Files
weichen fe5a973f15 Add run_command and run_python exec tools with process-group kill and env whitelist
Why: the kernel had no system-tool capability, so deliverable generation
via CLI/Python (the settled product direction) could not run inside
agent-native executions; squad's BashPermission gate had nothing to
guard.

Evidence: go vet ./... clean; go test ./... all ok. Cases cover echo
and non-zero exit reporting, env whitelist isolation (host secrets do
not leak into children), timeout and context-cancel killing the whole
process group promptly, blocklist rejection (sudo/destructive
patterns), 64 KiB head+tail output truncation, python script
persistence under .agent/scripts/, and fail-closed registration (no
interpreter configured = no tool). Adapter tests verify Options.Exec
wiring, per-request Env merge, allowlist stripping, and absence
without Options.Exec.

Impact: native.Options gains Exec *tools.ExecConfig; exec.Request.Env
now flows into child processes; workspace profile gains up to 2 tools
(only when configured). NOT an OS sandbox - isolation stays a host
responsibility, documented in tool descriptions.

Refs: parity-matrix #3/#4; settled product decision (deliverables via
CLI/Python + skills, see archived card feature-exec-interpreters).
2026-10-01 05:31:43 +00:00

167 lines
5.3 KiB
Go

package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func lookInterpreter(t *testing.T, name string) string {
t.Helper()
p, err := exec.LookPath(name)
if err != nil {
t.Fatalf("%s interpreter not available on host: %v", name, err)
}
return p
}
func TestRunCommandEchoAndExitCode(t *testing.T) {
bash := lookInterpreter(t, "bash")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
res := runTool(t, reg, "run_command", `{"command":"echo hello"}`)
if res.IsError || !strings.Contains(res.Content, "hello") || !strings.Contains(res.Content, "[exit code 0]") {
t.Fatalf("echo failed: %+v", res)
}
res = runTool(t, reg, "run_command", `{"command":"echo boom >&2; exit 3"}`)
if !res.IsError || !strings.Contains(res.Content, "boom") || !strings.Contains(res.Content, "[exit code 3]") {
t.Fatalf("non-zero exit must be reported as text: %+v", res)
}
}
func TestRunCommandEnvWhitelist(t *testing.T) {
bash := lookInterpreter(t, "bash")
t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{
Bash: bash,
Env: []string{"PATH=/usr/bin:/bin", "ALLOWED_FLAG=yes"},
})
res := runTool(t, reg, "run_command", `{"command":"echo allowed=$ALLOWED_FLAG secret=${KERNEL_SECRET_LEAK:-absent}"}`)
if res.IsError {
t.Fatal(res.Content)
}
if !strings.Contains(res.Content, "allowed=yes") || !strings.Contains(res.Content, "secret=absent") {
t.Fatalf("env whitelist violated, got:\n%s", res.Content)
}
}
func TestRunCommandTimeoutKillsProcessGroup(t *testing.T) {
bash := lookInterpreter(t, "bash")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
start := time.Now()
res := runTool(t, reg, "run_command", `{"command":"sleep 30 & sleep 30","timeout_seconds":1}`)
elapsed := time.Since(start)
if !res.IsError || !strings.Contains(res.Content, "[timeout: process group killed]") {
t.Fatalf("want timeout kill, got %+v", res)
}
if elapsed > 10*time.Second {
t.Fatalf("process group must die promptly, took %s", elapsed)
}
}
func TestRunCommandContextCancelKillsGroup(t *testing.T) {
bash := lookInterpreter(t, "bash")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(200 * time.Millisecond)
cancel()
}()
start := time.Now()
res := reg.Execute(ctx, "run_command", []byte(`{"command":"sleep 30","timeout_seconds":60}`))
elapsed := time.Since(start)
if elapsed > 10*time.Second {
t.Fatalf("cancel must kill promptly, took %s", elapsed)
}
if !res.IsError {
t.Fatalf("canceled command must be an error result, got %+v", res)
}
}
func TestRunCommandBlocklist(t *testing.T) {
bash := lookInterpreter(t, "bash")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
for _, cmd := range []string{
`{"command":"sudo rm x"}`,
`{"command":"rm -rf /"}`,
`{"command":"shutdown now"}`,
} {
res := runTool(t, reg, "run_command", cmd)
if !res.IsError || !strings.Contains(res.Content, "blocklist") {
t.Fatalf("blocklist must reject %s, got %+v", cmd, res)
}
}
}
func TestRunCommandOutputTruncation(t *testing.T) {
bash := lookInterpreter(t, "bash")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
res := runTool(t, reg, "run_command", `{"command":"head -c 200000 /dev/zero | tr '\\0' 'x'"}`)
if res.IsError {
t.Fatal(res.Content)
}
if len(res.Content) > execMaxOutput+512 {
t.Fatalf("output must be truncated to ~%d bytes, got %d", execMaxOutput, len(res.Content))
}
if !strings.Contains(res.Content, "truncated") {
t.Fatalf("truncation marker missing, len=%d", len(res.Content))
}
}
func TestRunPythonPersistsScriptAndRuns(t *testing.T) {
python := lookInterpreter(t, "python3")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Python: python, Env: []string{"PATH=/usr/bin:/bin"}})
res := runTool(t, reg, "run_python", `{"code":"print(2 + 3)\n"}`)
if res.IsError || !strings.Contains(res.Content, "5") {
t.Fatalf("python run failed: %+v", res)
}
if !strings.Contains(res.Content, "script: .agent/scripts/") {
t.Fatalf("script path must be reported, got:\n%s", res.Content)
}
matches, _ := filepath.Glob(filepath.Join(root, ".agent", "scripts", "*.py"))
if len(matches) != 1 {
t.Fatalf("script must be persisted exactly once, got %v", matches)
}
// 脚本内容落盘可审计。
data, _ := os.ReadFile(matches[0])
if !strings.Contains(string(data), "print(2 + 3)") {
t.Fatalf("persisted script content wrong: %q", data)
}
}
func TestRegisterExecToolsSkipsMissingInterpreters(t *testing.T) {
reg := NewRegistry(t.TempDir())
RegisterExecTools(reg, t.TempDir(), ExecConfig{}) // 无解释器:两个工具都不注册
names := make(map[string]bool)
for _, d := range reg.Decls() {
names[d.Name] = true
}
if names["run_command"] || names["run_python"] {
t.Fatalf("no interpreter configured, exec tools must not register, got %v", names)
}
}