1package agent
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16
17 "github.com/bnema/zut/packages/agent/tools"
18 "github.com/bnema/zut/packages/tui"
19)
20
21func TestReadZutfileConsentKeyAcceptsOnlyYesNo(t *testing.T) {
22 tests := []struct {
23 name string
24 input []byte
25 want byte
26 }{
27 {name: "lowercase yes", input: []byte{'y', '\r'}, want: 'y'},
28 {name: "uppercase yes", input: []byte{'Y', '\n'}, want: 'y'},
29 {name: "lowercase no", input: []byte{'n', '\r'}, want: 'n'},
30 {name: "uppercase no", input: []byte{'N', '\n'}, want: 'n'},
31 {name: "ignore unrelated keys", input: append([]byte("x\r\n\x1b[A"), 'y', '\r'), want: 'y'},
32 {name: "keep first valid answer", input: []byte{'n', 'y', '\r'}, want: 'n'},
33 {name: "backspace changes answer", input: []byte{'y', 0x7f, 'n', '\r'}, want: 'n'},
34 {name: "ctrl h changes answer", input: []byte{'n', '\b', 'y', '\r'}, want: 'y'},
35 {name: "backspace without answer is ignored", input: []byte{0x7f, 'y', '\r'}, want: 'y'},
36 }
37 for _, tt := range tests {
38 t.Run(tt.name, func(t *testing.T) {
39 var out bytes.Buffer
40 got, err := readZutfileConsentKey(bytes.NewReader(tt.input), func(answer byte) {
41 if answer == 0 {
42 out.Truncate(out.Len() - 1)
43 return
44 }
45 out.WriteByte(answer)
46 })
47 if err != nil {
48 t.Fatal(err)
49 }
50 if got != tt.want {
51 t.Fatalf("answer = %q, want %q", got, tt.want)
52 }
53 if out.String() != string(tt.want) {
54 t.Fatalf("echo = %q, want %q", out.String(), tt.want)
55 }
56 })
57 }
58}
59
60func TestReadZutfileConsentKeyWaitsForEnter(t *testing.T) {
61 var out bytes.Buffer
62 if _, err := readZutfileConsentKey(strings.NewReader("y"), func(answer byte) {
63 out.WriteByte(answer)
64 }); !errors.Is(err, io.EOF) {
65 t.Fatalf("error = %v, want EOF before Enter", err)
66 }
67 if out.String() != "y" {
68 t.Fatalf("echo = %q, want y", out.String())
69 }
70}
71
72func TestReadZutfileConsentKeyInterruptsOnCtrlC(t *testing.T) {
73 if _, err := readZutfileConsentKey(bytes.NewReader([]byte{'x', 3, 'y'}), nil); !errors.Is(err, errZutfileConsentInterrupted) {
74 t.Fatalf("error = %v, want interrupted", err)
75 }
76}
77
78func TestReadZutfileConsentKeyReportsInputError(t *testing.T) {
79 if _, err := readZutfileConsentKey(strings.NewReader("xxx"), nil); !errors.Is(err, io.EOF) {
80 t.Fatalf("error = %v, want EOF", err)
81 }
82}
83
84func writeTestZutfile(t *testing.T, manifest string) string {
85 t.Helper()
86 dir := t.TempDir()
87 if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil {
88 t.Fatal(err)
89 }
90 if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil {
91 t.Fatal(err)
92 }
93 return dir
94}
95
96func TestLoadZutfileAllowsMissingAgentMD(t *testing.T) {
97 dir := t.TempDir()
98 if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zutfile":1,"name":"test"}`), 0o600); err != nil {
99 t.Fatal(err)
100 }
101 zf, cleanup, err := loadZutfile(dir)
102 if cleanup != nil {
103 defer cleanup()
104 }
105 if err != nil {
106 t.Fatalf("load without AGENT.md: %v", err)
107 }
108 if zf.Manifest.Name != "test" {
109 t.Fatalf("name = %q, want test", zf.Manifest.Name)
110 }
111}
112
113func TestValidateZutfileDirRejectsAgentMDDirectory(t *testing.T) {
114 dir := t.TempDir()
115 if err := os.Mkdir(filepath.Join(dir, "AGENT.md"), 0o700); err != nil {
116 t.Fatal(err)
117 }
118 if err := validateZutfileDir(dir); err == nil || !strings.Contains(err.Error(), "regular file") {
119 t.Fatalf("unexpected error: %v", err)
120 }
121}
122
123func TestLoadZutfileRejectsUnenforcedPermissions(t *testing.T) {
124 for _, field := range []string{
125 `"net":{"allow":["example.com"]}`,
126 `"env":{"read":["HOME"]}`,
127 } {
128 dir := writeTestZutfile(t, `{"zutfile":1,"name":"test","permissions":{`+field+`}}`)
129 if _, _, err := loadZutfile(dir); err == nil {
130 t.Fatalf("manifest with %s was accepted", field)
131 }
132 }
133}
134
135func TestLoadZutfileBashModes(t *testing.T) {
136 for _, mode := range []string{"none", "ask", "allowlist"} {
137 manifest := `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"` + mode + `"}}}`
138 if mode == "allowlist" {
139 manifest = `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"allowlist","allow":["git"]}}}`
140 }
141 dir := writeTestZutfile(t, manifest)
142 if _, _, err := loadZutfile(dir); err != nil {
143 t.Fatalf("mode %q rejected: %v", mode, err)
144 }
145 }
146 dir := writeTestZutfile(t, `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"all"}}}`)
147 if _, _, err := loadZutfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") {
148 t.Fatalf("unexpected error for all mode: %v", err)
149 }
150 dir = writeTestZutfile(t, `{"zutfile":1,"name":"test","permissions":{"bash":{"mode":"everything"}}}`)
151 if _, _, err := loadZutfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") {
152 t.Fatalf("unexpected error for unknown mode: %v", err)
153 }
154}
155
156func TestLoadZutfileRejectsUnsafeOrCollidingNames(t *testing.T) {
157 for _, name := range []string{"...", "Name", "two words", "a/b"} {
158 dir := writeTestZutfile(t, `{"zutfile":1,"name":"`+name+`"}`)
159 if _, _, err := loadZutfile(dir); err == nil {
160 t.Fatalf("unsafe manifest name %q was accepted", name)
161 }
162 }
163}
164
165func TestResolveZutfileRefUsesLocalFirstThenGitHubShorthand(t *testing.T) {
166 dir := t.TempDir()
167 t.Chdir(dir)
168 if err := os.Mkdir("local-agent", 0o700); err != nil {
169 t.Fatal(err)
170 }
171 if err := os.WriteFile("packed-agent.zut", []byte("test"), 0o600); err != nil {
172 t.Fatal(err)
173 }
174
175 tests := []struct {
176 ref string
177 want string
178 }{
179 {"local-agent", "local-agent"},
180 {"packed-agent", "packed-agent.zut"},
181 {"remote-agent", "remote-agent"},
182 {"missing.zut", "missing.zut"},
183 {"agents/remote-agent", "https://github.com/agents/remote-agent"},
184 {`agents\remote-agent`, "https://github.com/agents/remote-agent"},
185 {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
186 {"acme/agents/reviewer", "https://github.com/acme/agents/reviewer"},
187 {`acme\agents\reviewers\go`, "https://github.com/acme/agents/reviewers/go"},
188 {"./missing-agent", "./missing-agent"},
189 {"Remote-Agent", "Remote-Agent"},
190 {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},
191 }
192 for _, tt := range tests {
193 got, err := resolveZutfileRef(tt.ref)
194 if err != nil {
195 t.Fatalf("resolve %q: %v", tt.ref, err)
196 }
197 if got != tt.want {
198 t.Errorf("resolve %q = %q, want %q", tt.ref, got, tt.want)
199 }
200 }
201}
202
203func TestLoadZutfileRejectsBundledExecutableExtension(t *testing.T) {
204 dir := writeTestZutfile(t, `{"zutfile":1,"name":"test"}`)
205 ext := filepath.Join(dir, "extensions", "bad")
206 if err := os.MkdirAll(ext, 0o700); err != nil {
207 t.Fatal(err)
208 }
209 if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
210 t.Fatal(err)
211 }
212 if _, _, err := loadZutfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
213 t.Fatalf("unexpected error: %v", err)
214 }
215}
216
217func TestCheckZutfileMinVersion(t *testing.T) {
218 var zf zutfileLoaded
219 zf.Manifest.Runtime.MinZut = "0.3.0"
220 if err := checkZutfileRequirements(zf, "0.2.75"); err == nil {
221 t.Fatal("old zut version accepted")
222 }
223 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {
224 t.Fatalf("minimum version rejected: %v", err)
225 }
226}
227
228func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
229 var m ZutfileManifest
230 m.Model.MinTier = "frontier"
231 if err := applyZutfileModelRequirements(&Args{}, m); err == nil {
232 t.Fatal("unsupported min_tier was ignored")
233 }
234 m.Model.MinTier = ""
235 m.Model.Requires = []string{"audio"}
236 if err := applyZutfileModelRequirements(&Args{}, m); err == nil {
237 t.Fatal("unsupported capability was ignored")
238 }
239}
240
241func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
242 makeTar := func(name string, size int64) []byte {
243 var buf bytes.Buffer
244 tw := tar.NewWriter(&buf)
245 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
246 t.Fatal(err)
247 }
248 _ = tw.Close()
249 return buf.Bytes()
250 }
251 if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
252 t.Fatal("path traversal accepted")
253 }
254 if err := untar(bytes.NewReader(makeTar("large", maxZutfileEntrySize+1)), t.TempDir()); err == nil {
255 t.Fatal("oversized entry accepted")
256 }
257}
258
259func TestParseGitHubAgentURL(t *testing.T) {
260 tests := []struct {
261 input string
262 owner, repo, ref, dir string
263 }{
264 {"https://github.com/acme/agents/reviewer", "acme", "agents", "HEAD", "reviewer"},
265 {"https://github.com/acme/agents/tree/main/reviewer", "acme", "agents", "main", "reviewer"},
266 {"https://github.com/acme/agents/reviewers/go", "acme", "agents", "HEAD", "reviewers/go"},
267 {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
268 }
269 for _, tt := range tests {
270 u, err := url.Parse(tt.input)
271 if err != nil {
272 t.Fatal(err)
273 }
274 owner, repo, ref, dir, err := parseGitHubAgentURL(u)
275 if err != nil {
276 t.Fatalf("parse %s: %v", tt.input, err)
277 }
278 if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
279 t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
280 }
281 }
282}
283
284func TestLoadRemoteZutfileDownloadsTemporaryGitHubArchive(t *testing.T) {
285 var archive bytes.Buffer
286 gz := gzip.NewWriter(&archive)
287 tw := tar.NewWriter(gz)
288 files := map[string]string{
289 "agents-main/zut-maintenance/manifest.json": `{"zutfile":1,"name":"zut-maintenance"}`,
290 "agents-main/zut-maintenance/AGENT.md": "Maintain zut.",
291 }
292 for name, content := range files {
293 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
294 t.Fatal(err)
295 }
296 if _, err := tw.Write([]byte(content)); err != nil {
297 t.Fatal(err)
298 }
299 }
300 if err := tw.Close(); err != nil {
301 t.Fatal(err)
302 }
303 if err := gz.Close(); err != nil {
304 t.Fatal(err)
305 }
306
307 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308 w.Header().Set("Content-Type", "application/gzip")
309 _, _ = w.Write(archive.Bytes())
310 }))
311 defer server.Close()
312 oldArchiveURL := githubArchiveURL
313 githubArchiveURL = func(_, _, _ string) string { return server.URL }
314 t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
315
316 u, _ := url.Parse("https://github.com/acme/agents/zut-maintenance")
317 zf, cleanup, err := loadRemoteZutfile(u)
318 if err != nil {
319 t.Fatal(err)
320 }
321 if zf.Manifest.Name != "zut-maintenance" || !zf.Temp {
322 t.Fatalf("unexpected zutfile: %+v", zf)
323 }
324 root := filepath.Dir(filepath.Dir(zf.Dir))
325 cleanup()
326 if _, err := os.Stat(root); !os.IsNotExist(err) {
327 t.Fatalf("temporary checkout was not removed: %v", err)
328 }
329}
330
331func TestFormatZutfileConsentUsesThemeColors(t *testing.T) {
332 th := tui.Theme{
333 FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3),
334 Assistant: tui.Color256(4), Warning: tui.Color256(5),
335 }
336 zf := zutfileLoaded{}
337 zf.Manifest.Name = "reviewer"
338 zf.Manifest.Version = "1.2.3"
339 perms := tools.PermissionSet{}
340 perms.FS.Read = []string{"/workspace"}
341 perms.Bash.Mode = "ask"
342
343 got := formatZutfileConsent(zf, perms, th, true)
344 for _, want := range []string{
345 th.FGColor(th.Assistant, "Agent"),
346 th.FGColor(th.Accent, "reviewer@1.2.3"),
347 th.FGColor(th.Muted, " fs read: "),
348 th.FGColor(th.FG, "/workspace"),
349 th.FGColor(th.Warning, "ask"),
350 th.FGColor(th.Assistant, "Allow?"),
351 th.FGColor(th.Muted, "[y/n]"),
352 } {
353 if !strings.Contains(got, want) {
354 t.Fatalf("themed consent missing %q:\n%s", want, got)
355 }
356 }
357}
358
359func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
360 got := permissionSummary(tools.PermissionSet{})
361 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
362 t.Fatalf("summary did not show denied scopes:\n%s", got)
363 }
364}
365
366func TestPeelLeadingYes(t *testing.T) {
367 yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"})
368 if !yes {
369 t.Fatal("expected yes")
370 }
371 if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" {
372 t.Fatalf("rest = %v", rest)
373 }
374 yes, rest = peelLeadingYes([]string{"agent", "-y"})
375 if yes || len(rest) != 2 {
376 t.Fatalf("yes=%v rest=%v", yes, rest)
377 }
378}
379
380func TestConsentZutfileAutoYesWritesReceipt(t *testing.T) {
381 home := t.TempDir()
382 t.Setenv("ZUT_HOME", home)
383 zf := zutfileLoaded{Digest: "abc123", Manifest: ZutfileManifest{Name: "demo", Version: "1.0.0"}}
384 var perms tools.PermissionSet
385 perms.Bash.Mode = "none"
386 allowed, err := consentZutfile(zf, perms, true)
387 if err != nil || !allowed {
388 t.Fatalf("auto yes: allowed=%v err=%v", allowed, err)
389 }
390 path := filepath.Join(home, "agents", "demo", "consents", "abc123.json")
391 if _, err := os.Stat(path); err != nil {
392 t.Fatalf("consent receipt missing: %v", err)
393 }
394 // Second call without -y should use the receipt.
395 allowed, err = consentZutfile(zf, perms, false)
396 if err != nil || !allowed {
397 t.Fatalf("cached consent: allowed=%v err=%v", allowed, err)
398 }
399}
400
401func TestConsentZutfileAutoYesAskDoesNotCache(t *testing.T) {
402 home := t.TempDir()
403 t.Setenv("ZUT_HOME", home)
404 zf := zutfileLoaded{Digest: "askdig", Manifest: ZutfileManifest{Name: "ask-agent", Version: "1.0.0"}}
405 var perms tools.PermissionSet
406 perms.Bash.Mode = "ask"
407 if _, err := consentZutfile(zf, perms, true); err != nil {
408 t.Fatal(err)
409 }
410 path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json")
411 if _, err := os.Stat(path); !os.IsNotExist(err) {
412 t.Fatalf("ask mode should not cache consent, err=%v", err)
413 }
414}