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") } }