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
263 lines
8.5 KiB
Go
263 lines
8.5 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 TestRunCommandNilEnvIsEmptyWhitelist(t *testing.T) {
|
|
bash := lookInterpreter(t, "bash")
|
|
t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak")
|
|
root := t.TempDir()
|
|
reg := NewRegistry(root)
|
|
// Env left nil: must normalize to empty whitelist (not inherit host).
|
|
RegisterExecTools(reg, root, ExecConfig{Bash: bash})
|
|
res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`)
|
|
if res.IsError {
|
|
t.Fatal(res.Content)
|
|
}
|
|
if !strings.Contains(res.Content, "secret=absent") {
|
|
t.Fatalf("nil Env must not inherit host secrets, got:\n%s", res.Content)
|
|
}
|
|
}
|
|
|
|
func TestRunCommandExplicitEmptyEnvStaysEmpty(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{}})
|
|
res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`)
|
|
if res.IsError {
|
|
t.Fatal(res.Content)
|
|
}
|
|
if !strings.Contains(res.Content, "secret=absent") {
|
|
t.Fatalf("explicit empty Env must stay empty, 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 TestHeadTailBufferBoundsMemoryAndPreservesSmall(t *testing.T) {
|
|
buf := newHeadTailBuffer(64)
|
|
if _, err := buf.Write([]byte("hello")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, total := buf.Snapshot()
|
|
if total != 5 || string(out) != "hello" {
|
|
t.Fatalf("small output must be unchanged, got %q total=%d", out, total)
|
|
}
|
|
|
|
buf = newHeadTailBuffer(64)
|
|
var payload strings.Builder
|
|
for i := 0; i < 200; i++ {
|
|
payload.WriteByte(byte('A' + (i % 26)))
|
|
}
|
|
if _, err := buf.Write([]byte(payload.String())); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, total = buf.Snapshot()
|
|
if total != 200 {
|
|
t.Fatalf("total must count all bytes, got %d", total)
|
|
}
|
|
if len(out) != 64 {
|
|
t.Fatalf("retained buffer must be capped at 64, got %d", len(out))
|
|
}
|
|
if string(out[:32]) != payload.String()[:32] {
|
|
t.Fatalf("head mismatch: %q", out[:32])
|
|
}
|
|
if string(out[32:]) != payload.String()[200-32:] {
|
|
t.Fatalf("tail mismatch: %q", out[32:])
|
|
}
|
|
// Internal retention stays O(max), not O(total).
|
|
if cap(buf.head)+cap(buf.tail) > 64+8 {
|
|
t.Fatalf("buffer capacity must stay near max, head=%d tail=%d", cap(buf.head), cap(buf.tail))
|
|
}
|
|
}
|
|
|
|
func TestRunCommandChattyOutputCancelDrains(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(150 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
start := time.Now()
|
|
// Chatty child: continuous stdout until killed; capture must stay bounded and Wait must reap.
|
|
res := reg.Execute(ctx, "run_command", []byte(
|
|
`{"command":"while true; do head -c 8192 /dev/zero | tr '\\0' 'y'; done","timeout_seconds":60}`,
|
|
))
|
|
elapsed := time.Since(start)
|
|
if elapsed > 10*time.Second {
|
|
t.Fatalf("cancel must drain/reap promptly, took %s", elapsed)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatalf("canceled chatty command must be an error result, got %+v", res)
|
|
}
|
|
if len(res.Content) > execMaxOutput+512 {
|
|
t.Fatalf("chatty cancel output must stay capped, got %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)
|
|
}
|
|
}
|