339 lines
13 KiB
Go
339 lines
13 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sysagent/internal/audit"
|
|
"sysagent/internal/memory"
|
|
"sysagent/internal/policy"
|
|
)
|
|
|
|
// newEnv crea un proyecto temporal con la política indicada.
|
|
func newEnv(t *testing.T, directives string) (*Registry, *Env, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "agent.md"), []byte("x"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "init.conf"), []byte("API_KEY=sk-secret-in-file-123"), 0o600)
|
|
p, err := policy.Parse("```policy\n" + directives + "\n```\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g, err := policy.NewGuard(p, dir, "agent.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
log, _ := audit.New(io.Discard, filepath.Join(dir, "agent.log"), false)
|
|
t.Cleanup(func() { log.Close() })
|
|
env := &Env{Guard: g, Policy: p, Log: log, Root: dir, Memory: memory.NewStore(filepath.Join(dir, "memoria.md")),
|
|
Now: func() time.Time { return time.Date(2026, 9, 27, 10, 30, 0, 0, time.UTC) }}
|
|
return NewRegistry(env), env, dir
|
|
}
|
|
|
|
func call(t *testing.T, r *Registry, name string, args map[string]any) Result {
|
|
t.Helper()
|
|
b, _ := json.Marshal(args)
|
|
return r.Execute(context.Background(), "test", name, string(b))
|
|
}
|
|
|
|
func TestExec(t *testing.T) {
|
|
r, _, _ := newEnv(t, "EXECUTION_MODE=guarded")
|
|
res := call(t, r, "exec", map[string]any{"command": "printf", "args": []string{"%s-%s", "a b", "c"}})
|
|
if !res.OK || !strings.Contains(res.Output, "a b-c") || !strings.Contains(res.Output, "exit_code: 0") {
|
|
t.Fatalf("exec printf: %+v", res)
|
|
}
|
|
res = call(t, r, "exec", map[string]any{"command": "false"})
|
|
if res.OK || res.ExitCode != 1 {
|
|
t.Errorf("false must fail with exit 1: %+v", res)
|
|
}
|
|
res = call(t, r, "exec", map[string]any{"command": "definitely-not-a-program-xyz"})
|
|
if res.OK {
|
|
t.Error("unknown program should fail")
|
|
}
|
|
}
|
|
|
|
func TestExecTimeoutKillsProcessGroup(t *testing.T) {
|
|
r, _, _ := newEnv(t, "EXECUTION_MODE=privileged")
|
|
start := time.Now()
|
|
// sh lanza un hijo sleep: el timeout debe matar a ambos sin esperar 30s.
|
|
res := call(t, r, "shell", map[string]any{"script": "sleep 30 & sleep 30", "timeout_seconds": 1})
|
|
if time.Since(start) > 10*time.Second {
|
|
t.Fatalf("timeout did not kill process group (took %s)", time.Since(start))
|
|
}
|
|
if res.OK || !strings.Contains(res.Output, "timed_out: true") {
|
|
t.Errorf("expected timeout: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestShellPipesAndDenials(t *testing.T) {
|
|
r, _, dir := newEnv(t, "EXECUTION_MODE=guarded")
|
|
res := call(t, r, "shell", map[string]any{"script": "echo hello world | tr a-z A-Z"})
|
|
if !res.OK || !strings.Contains(res.Output, "HELLO WORLD") {
|
|
t.Fatalf("pipe: %+v", res)
|
|
}
|
|
res = call(t, r, "shell", map[string]any{"script": "echo x > /tmp/sysagent-should-not-exist"})
|
|
if !res.Denied {
|
|
t.Errorf("guarded: write outside project must be denied: %+v", res)
|
|
}
|
|
res = call(t, r, "shell", map[string]any{"script": "cat init.conf"})
|
|
if !res.Denied {
|
|
t.Errorf("init.conf must not be readable: %+v", res)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "x")); err == nil {
|
|
t.Error("unexpected file created")
|
|
}
|
|
}
|
|
|
|
func TestLargeOutputSpills(t *testing.T) {
|
|
r, _, dir := newEnv(t, "EXECUTION_MODE=privileged")
|
|
res := call(t, r, "shell", map[string]any{"script": "seq 1 100000"})
|
|
if !res.OK {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
if len(res.Output) > 20<<10 {
|
|
t.Errorf("output to model too large: %d bytes", len(res.Output))
|
|
}
|
|
if !strings.Contains(res.Output, "bytes omitted") || !strings.Contains(res.Output, "100000") {
|
|
t.Errorf("expected head/tail with omission note:\n%s", clip(res.Output, 500))
|
|
}
|
|
matches, _ := filepath.Glob(filepath.Join(dir, ".agent", "tmp", "*stdout*.out"))
|
|
if len(matches) != 1 {
|
|
t.Fatalf("expected spill file, got %v", matches)
|
|
}
|
|
data, _ := os.ReadFile(matches[0])
|
|
if !strings.HasSuffix(string(data), "100000\n") {
|
|
t.Error("spill file must contain the full output")
|
|
}
|
|
}
|
|
|
|
func TestReadFile(t *testing.T) {
|
|
r, _, dir := newEnv(t, "")
|
|
os.WriteFile(filepath.Join(dir, "f.txt"), []byte("uno\ndos\ntres\n"), 0o644)
|
|
res := call(t, r, "read_file", map[string]any{"path": "f.txt", "offset": 2, "limit": 1})
|
|
if !res.OK || !strings.Contains(res.Output, "2\tdos") || strings.Contains(res.Output, "tres") {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
res = call(t, r, "read_file", map[string]any{"path": "init.conf"})
|
|
if !res.Denied {
|
|
t.Error("init.conf must be denied")
|
|
}
|
|
res = call(t, r, "read_file", map[string]any{"path": "/etc/hostname"})
|
|
if !res.Denied {
|
|
t.Error("outside read must need ALLOW_OUTSIDE_PROJECT_READ")
|
|
}
|
|
}
|
|
|
|
func TestWriteFileAtomicIdempotentAndValidate(t *testing.T) {
|
|
r, _, dir := newEnv(t, "")
|
|
res := call(t, r, "write_file", map[string]any{"path": "conf/app.conf", "content": "a=1\n", "create_dirs": true, "mode": "0640"})
|
|
if !res.OK {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
st, _ := os.Stat(filepath.Join(dir, "conf/app.conf"))
|
|
if st.Mode().Perm() != 0o640 {
|
|
t.Errorf("mode = %o", st.Mode().Perm())
|
|
}
|
|
res = call(t, r, "write_file", map[string]any{"path": "conf/app.conf", "content": "a=1\n"})
|
|
if !res.OK || !strings.Contains(res.Output, "nothing written") {
|
|
t.Errorf("idempotent write: %+v", res)
|
|
}
|
|
// Validación fallida: el original no cambia.
|
|
res = call(t, r, "write_file", map[string]any{"path": "conf/app.conf", "content": "broken",
|
|
"validate": map[string]any{"command": "grep", "args": []string{"-q", "=", "{file}"}}})
|
|
if res.OK {
|
|
t.Fatal("validation should fail")
|
|
}
|
|
if data, _ := os.ReadFile(filepath.Join(dir, "conf/app.conf")); string(data) != "a=1\n" {
|
|
t.Errorf("original modified after failed validation: %q", data)
|
|
}
|
|
// Validación correcta.
|
|
res = call(t, r, "write_file", map[string]any{"path": "conf/app.conf", "content": "a=2\n",
|
|
"validate": map[string]any{"command": "grep", "args": []string{"-q", "=", "{file}"}}})
|
|
if !res.OK {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
// No quedan temporales.
|
|
left, _ := filepath.Glob(filepath.Join(dir, "conf", ".*sysagent-tmp*"))
|
|
if len(left) != 0 {
|
|
t.Errorf("temporary files left: %v", left)
|
|
}
|
|
}
|
|
|
|
func TestWriteProtectedFiles(t *testing.T) {
|
|
r, _, _ := newEnv(t, "EXECUTION_MODE=unrestricted\nALLOW_OUTSIDE_PROJECT_WRITE=true")
|
|
for _, p := range []string{"agent.md", "init.conf", "03-new-task.md", "agent.log"} {
|
|
res := call(t, r, "write_file", map[string]any{"path": p, "content": "x"})
|
|
if !res.Denied {
|
|
t.Errorf("%s must be protected: %+v", p, res)
|
|
}
|
|
}
|
|
if res := call(t, r, "write_file", map[string]any{"path": "memoria.md", "content": "# m\n"}); !res.OK {
|
|
t.Errorf("memoria.md must be writable: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestBackupOutsideProject(t *testing.T) {
|
|
r, _, _ := newEnv(t, "EXECUTION_MODE=privileged\nALLOW_OUTSIDE_PROJECT_READ=true\nALLOW_OUTSIDE_PROJECT_WRITE=true")
|
|
outside := t.TempDir()
|
|
target := filepath.Join(outside, "sshd_config")
|
|
os.WriteFile(target, []byte("Port 22\n"), 0o600)
|
|
|
|
res := call(t, r, "edit_file", map[string]any{"path": target, "old_string": "Port 22", "new_string": "Port 2222", "reason": "test"})
|
|
if !res.OK {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
backup := target + ".sysagent.20260927-103000.bak"
|
|
if len(res.Backups) != 1 || res.Backups[0] != backup {
|
|
t.Fatalf("backups = %v", res.Backups)
|
|
}
|
|
if data, _ := os.ReadFile(backup); string(data) != "Port 22\n" {
|
|
t.Errorf("backup content = %q", data)
|
|
}
|
|
if st, _ := os.Stat(backup); st.Mode().Perm() != 0o600 {
|
|
t.Errorf("backup must keep permissions, got %o", st.Mode().Perm())
|
|
}
|
|
if data, _ := os.ReadFile(target); string(data) != "Port 2222\n" {
|
|
t.Errorf("target = %q", data)
|
|
}
|
|
if st, _ := os.Stat(target); st.Mode().Perm() != 0o600 {
|
|
t.Errorf("edit must keep permissions, got %o", st.Mode().Perm())
|
|
}
|
|
if !strings.Contains(res.Output, "rollback: copy_file src="+backup) {
|
|
t.Errorf("rollback info missing: %s", res.Output)
|
|
}
|
|
// Segunda edición en el mismo segundo: backup con sufijo, sin sobrescribir.
|
|
res = call(t, r, "edit_file", map[string]any{"path": target, "old_string": "2222", "new_string": "2200"})
|
|
if !res.OK || res.Backups[0] != backup+".1" {
|
|
t.Errorf("second backup: %+v", res.Backups)
|
|
}
|
|
}
|
|
|
|
func TestEditFileErrors(t *testing.T) {
|
|
r, _, dir := newEnv(t, "")
|
|
os.WriteFile(filepath.Join(dir, "f"), []byte("a a"), 0o644)
|
|
if res := call(t, r, "edit_file", map[string]any{"path": "f", "old_string": "a", "new_string": "b"}); res.OK {
|
|
t.Error("ambiguous edit must fail")
|
|
}
|
|
if res := call(t, r, "edit_file", map[string]any{"path": "f", "old_string": "zz", "new_string": "b"}); res.OK {
|
|
t.Error("missing old_string must fail")
|
|
}
|
|
if res := call(t, r, "edit_file", map[string]any{"path": "f", "old_string": "a", "new_string": "b", "replace_all": true}); !res.OK {
|
|
t.Errorf("%+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRemoveRequiresAllowDelete(t *testing.T) {
|
|
r, _, dir := newEnv(t, "")
|
|
os.WriteFile(filepath.Join(dir, "junk"), []byte("x"), 0o644)
|
|
if res := call(t, r, "remove_file", map[string]any{"path": "junk"}); !res.Denied {
|
|
t.Errorf("delete without ALLOW_DELETE: %+v", res)
|
|
}
|
|
r2, _, dir2 := newEnv(t, "ALLOW_DELETE=true")
|
|
os.MkdirAll(filepath.Join(dir2, "d", "e"), 0o755)
|
|
if res := call(t, r2, "remove_file", map[string]any{"path": "d"}); res.OK {
|
|
t.Error("non-empty dir without recursive must fail")
|
|
}
|
|
if res := call(t, r2, "remove_file", map[string]any{"path": "d", "recursive": true}); !res.OK {
|
|
t.Errorf("%+v", res)
|
|
}
|
|
if res := call(t, r2, "remove_file", map[string]any{"path": dir2}); !res.Denied {
|
|
t.Error("PROJECT_ROOT deletion must be denied")
|
|
}
|
|
}
|
|
|
|
func TestDryRun(t *testing.T) {
|
|
r, env, dir := newEnv(t, "EXECUTION_MODE=privileged\nALLOW_DELETE=true")
|
|
env.DryRun = true
|
|
res := call(t, r, "write_file", map[string]any{"path": "x.txt", "content": "x"})
|
|
if !res.OK || !strings.HasPrefix(res.Output, "DRY-RUN") {
|
|
t.Errorf("%+v", res)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "x.txt")); err == nil {
|
|
t.Error("dry-run must not write")
|
|
}
|
|
res = call(t, r, "shell", map[string]any{"script": "touch y.txt"})
|
|
if !strings.HasPrefix(res.Output, "DRY-RUN") {
|
|
t.Errorf("dry-run shell write: %+v", res)
|
|
}
|
|
// Los comandos de solo lectura sí se ejecutan en dry-run (diagnóstico).
|
|
res = call(t, r, "exec", map[string]any{"command": "echo", "args": []string{"diag"}})
|
|
if !res.OK || !strings.Contains(res.Output, "diag") {
|
|
t.Errorf("read-only in dry-run: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestStrictArguments(t *testing.T) {
|
|
r, _, _ := newEnv(t, "")
|
|
res := r.Execute(context.Background(), "test", "exec", `{"command":"echo","rm_everything":true}`)
|
|
if res.OK || !strings.Contains(res.Output, "unknown field") {
|
|
t.Errorf("unknown fields must be rejected: %+v", res)
|
|
}
|
|
res = r.Execute(context.Background(), "test", "exec", `not json`)
|
|
if res.OK {
|
|
t.Error("invalid JSON must be rejected")
|
|
}
|
|
res = r.Execute(context.Background(), "test", "nope", `{}`)
|
|
if res.OK || !strings.Contains(res.Output, "unknown tool") {
|
|
t.Errorf("%+v", res)
|
|
}
|
|
}
|
|
|
|
func TestSecretsRedactedFromOutput(t *testing.T) {
|
|
r, env, dir := newEnv(t, "")
|
|
env.Log.AddSecret("sk-secret-in-file-123", "sk-sec...123")
|
|
os.WriteFile(filepath.Join(dir, "leak.txt"), []byte("key=sk-secret-in-file-123"), 0o644)
|
|
res := call(t, r, "read_file", map[string]any{"path": "leak.txt"})
|
|
if strings.Contains(res.Output, "sk-secret-in-file-123") {
|
|
t.Error("API key must be redacted from tool output")
|
|
}
|
|
}
|
|
|
|
func TestSystemInfo(t *testing.T) {
|
|
r, _, _ := newEnv(t, "")
|
|
res := call(t, r, "system_info", map[string]any{})
|
|
for _, k := range []string{"kernel:", "architecture:", "running_as_root:", "package_managers:"} {
|
|
if !strings.Contains(res.Output, k) {
|
|
t.Errorf("system_info missing %s", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRememberAndForget(t *testing.T) {
|
|
r, env, dir := newEnv(t, "")
|
|
env.Log.AddSecret("sk-secret-in-file-123", "sk-sec...123")
|
|
res := call(t, r, "remember", map[string]any{"section": "Hosts", "entry": "db01", "facts": []string{"IP: 10.10.10.15", "Puerto: 5432"}})
|
|
if !res.OK || len(res.FilesChanged) != 1 {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
res = call(t, r, "remember", map[string]any{"section": "Hosts", "entry": "db01", "facts": []string{"IP: 10.10.10.15"}})
|
|
if !strings.Contains(res.Output, "nothing changed") || len(res.FilesChanged) != 0 {
|
|
t.Errorf("duplicate: %+v", res)
|
|
}
|
|
res = call(t, r, "remember", map[string]any{"section": "Hosts", "entry": "db01", "facts": []string{"password: hunter2"}})
|
|
if res.OK {
|
|
t.Error("secret without sensitive=true must be rejected")
|
|
}
|
|
res = call(t, r, "remember", map[string]any{"section": "Credenciales", "entry": "api", "facts": []string{"Key: sk-secret-in-file-123"}, "sensitive": true})
|
|
data, _ := os.ReadFile(filepath.Join(dir, "memoria.md"))
|
|
if strings.Contains(string(data), "sk-secret-in-file-123") {
|
|
t.Error("API key must never be persisted in memoria.md")
|
|
}
|
|
if !strings.Contains(string(data), "### db01") || !strings.Contains(string(data), "- Puerto: 5432") {
|
|
t.Errorf("memoria.md:\n%s", data)
|
|
}
|
|
res = call(t, r, "forget", map[string]any{"section": "Hosts", "entry": "db01", "reason": "decommissioned"})
|
|
if !res.OK {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
data, _ = os.ReadFile(filepath.Join(dir, "memoria.md"))
|
|
if strings.Contains(string(data), "db01") {
|
|
t.Error("forget did not remove the entry")
|
|
}
|
|
}
|