1package agent
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "net/http"
14 "net/url"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "runtime"
19 "sort"
20 "strings"
21 "time"
22
23 "github.com/bnema/zut/packages/agent/tools"
24 "github.com/bnema/zut/packages/provider"
25 "github.com/bnema/zut/packages/provider/auth"
26 "github.com/bnema/zut/packages/tui"
27 "github.com/klauspost/compress/zstd"
28 "golang.org/x/term"
29)
30
31type ZutfileManifest struct {
32 Zutfile int `json:"zutfile"`
33 Name string `json:"name"`
34 Version string `json:"version"`
35 Description string `json:"description"`
36 License string `json:"license"`
37 Runtime struct {
38 MinZut string `json:"min_zut"`
39 } `json:"runtime"`
40 Model struct {
41 Requires []string `json:"requires"`
42 MinContext int `json:"min_context"`
43 Preferred []string `json:"preferred"`
44 MinTier string `json:"min_tier"`
45 } `json:"model"`
46 Permissions tools.PermissionSet `json:"permissions"`
47 Requirements struct {
48 Bin []string `json:"bin"`
49 OS []string `json:"os"`
50 } `json:"requirements"`
51 Entry struct {
52 Greeting string `json:"greeting"`
53 Pre string `json:"pre"`
54 DefaultPrompt *string `json:"default_prompt"`
55 } `json:"entry"`
56 ReplaceSystemPrompt bool `json:"replace_system_prompt"`
57}
58
59type zutfileLoaded struct {
60 Dir string
61 Temp bool
62 Digest string
63 Manifest ZutfileManifest
64}
65
66func runZutfileCommand(rawArgs []string, version string) (bool, error) {
67 if len(rawArgs) == 0 {
68 return false, nil
69 }
70 switch rawArgs[0] {
71 case "pack":
72 dir := "."
73 out := ""
74 if len(rawArgs) > 1 {
75 dir = rawArgs[1]
76 }
77 if len(rawArgs) > 2 {
78 out = rawArgs[2]
79 }
80 return true, zutPack(dir, out)
81 case "inspect":
82 if len(rawArgs) < 2 {
83 return true, fmt.Errorf("zut inspect requires a name, .zut file, directory, or GitHub URL")
84 }
85 return true, zutInspect(rawArgs[1])
86 case "verify":
87 if len(rawArgs) < 2 {
88 return true, fmt.Errorf("zut verify requires a name, .zut file, directory, or GitHub URL")
89 }
90 zf, cleanup, err := loadZutfile(rawArgs[1])
91 if cleanup != nil {
92 defer cleanup()
93 }
94 if err != nil {
95 return true, err
96 }
97 fmt.Printf("ok digest sha256:%s\n", zf.Digest)
98 return true, nil
99 case "run":
100 if len(rawArgs) < 2 {
101 return true, fmt.Errorf("zut run requires a name, .zut file, directory, or GitHub URL")
102 }
103 yes, remaining := peelLeadingYes(rawArgs[1:])
104 if len(remaining) < 1 {
105 return true, fmt.Errorf("zut run requires a name, .zut file, directory, or GitHub URL")
106 }
107 ref := remaining[0]
108 rest := remaining[1:]
109 args, err := ParseArgs(rest)
110 if err != nil {
111 PrintHelp(version)
112 return true, err
113 }
114 if yes {
115 args.Yes = true
116 }
117 if args.Orchestrate {
118 return true, fmt.Errorf("--orchestrate is not supported for packaged agents")
119 }
120 if err := validateOrchestrationArgs(args); err != nil {
121 return true, err
122 }
123 return true, runLocalZutfile(ref, args, version)
124 default:
125 return false, nil
126 }
127}
128
129func runLocalZutfile(ref string, args Args, version string) error {
130 prepareRuntimeCatalog()
131 zf, cleanup, err := loadZutfile(ref)
132 if cleanup != nil {
133 defer cleanup()
134 }
135 if err != nil {
136 return err
137 }
138 if err := checkZutfileRequirements(zf, version); err != nil {
139 return err
140 }
141 // A packaged agent has not yet had its manifest permissions attached to
142 // Args. Reject orchestration at this boundary, before creating its data
143 // directory or asking for permission consent.
144 if args.Orchestrate {
145 args.AgentName = zf.Manifest.Name
146 return validateOrchestrationArgs(args)
147 }
148 agentData := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
149 if err := os.MkdirAll(agentData, 0o755); err != nil {
150 return err
151 }
152 perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
153 allowed, err := consentZutfile(zf, perms, args.Yes)
154 if err != nil {
155 return err
156 }
157 if !allowed {
158 return nil
159 }
160 if err := applyZutfileModelRequirements(&args, zf.Manifest); err != nil {
161 return err
162 }
163 agentPath := filepath.Join(zf.Dir, "AGENT.md")
164 agentPrompt, err := os.ReadFile(agentPath)
165 switch {
166 case err == nil:
167 text := strings.TrimSpace(string(agentPrompt))
168 if zf.Manifest.ReplaceSystemPrompt {
169 args.SystemPrompt = text
170 } else {
171 args.AppendSystemPrompt = append(args.AppendSystemPrompt, text)
172 }
173 case os.IsNotExist(err):
174 // AGENT.md is optional; missing file leaves the system prompt unchanged.
175 default:
176 return fmt.Errorf("read AGENT.md: %w", err)
177 }
178 args.StartupPre = strings.TrimSpace(zf.Manifest.Entry.Pre)
179 if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
180 args.Prompt = *zf.Manifest.Entry.DefaultPrompt
181 }
182 if dirExists(filepath.Join(zf.Dir, "skills")) {
183 old := os.Getenv("ZUT_AGENT_SKILLS")
184 v := filepath.Join(zf.Dir, "skills")
185 if old != "" {
186 v += string(os.PathListSeparator) + old
187 }
188 _ = os.Setenv("ZUT_AGENT_SKILLS", v)
189 defer os.Setenv("ZUT_AGENT_SKILLS", old)
190 }
191 args.AgentName = zf.Manifest.Name
192 args.AgentDataDir = agentData
193 args.PermissionSet = &perms
194 return runWithArgs(args, version)
195}
196
197func zutInspect(ref string) error {
198 zf, cleanup, err := loadZutfile(ref)
199 if cleanup != nil {
200 defer cleanup()
201 }
202 if err != nil {
203 return err
204 }
205 m := zf.Manifest
206 fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
207 fmt.Println("\npermissions:")
208 fmt.Print(permissionSummary(m.Permissions))
209 fmt.Println("\nfiles:")
210 return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
211 if err != nil || path == zf.Dir {
212 return err
213 }
214 rel, _ := filepath.Rel(zf.Dir, path)
215 if d.IsDir() {
216 rel += "/"
217 }
218 fmt.Println(" " + filepath.ToSlash(rel))
219 return nil
220 })
221}
222
223func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error {
224 minCtx := m.Model.MinContext
225 requires := map[string]bool{}
226 for _, capability := range m.Model.Requires {
227 requires[strings.ToLower(strings.TrimSpace(capability))] = true
228 }
229 for capability := range requires {
230 if capability != "tools" && capability != "vision" && capability != "reasoning" {
231 return fmt.Errorf("unsupported model requirement %q", capability)
232 }
233 }
234 if m.Model.MinTier != "" {
235 return fmt.Errorf("model.min_tier is not supported by this zut version")
236 }
237 compatible := func(model provider.Model) bool {
238 if model.ContextWindow < minCtx {
239 return false
240 }
241 if requires["reasoning"] && !model.Reasoning {
242 return false
243 }
244 // Every model exposed by the current catalog supports text and tools.
245 // Vision support is not represented in Model yet, so fail closed.
246 return !requires["vision"]
247 }
248 if minCtx <= 0 && len(requires) == 0 {
249 return nil
250 }
251 if args.Model != "" {
252 model, err := provider.FindModel(args.Provider, args.Model)
253 if err != nil {
254 model, err = provider.FindModel("", args.Model)
255 }
256 if err != nil {
257 return nil
258 }
259 if !compatible(model) {
260 return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
261 }
262 return nil
263 }
264 cfg, _ := LoadConfig()
265 if cfg.Model != "" {
266 model, err := provider.FindModel(cfg.Provider, cfg.Model)
267 if err == nil && compatible(model) {
268 return nil
269 }
270 }
271 for _, id := range m.Model.Preferred {
272 model, err := provider.FindModel("", id)
273 if err == nil && compatible(model) {
274 args.Provider = model.Provider
275 args.Model = model.ID
276 return nil
277 }
278 }
279 for _, model := range provider.Active() {
280 if compatible(model) {
281 args.Provider = model.Provider
282 args.Model = model.ID
283 return nil
284 }
285 }
286 return fmt.Errorf("no catalog model satisfies the agent requirements")
287}
288
289func prepareRuntimeCatalog() {
290 LoadCachedModels()
291 LoadUserModels()
292 if cps := provider.CustomProviders(); len(cps) > 0 {
293 var names []string
294 for name := range cps {
295 if !isBuiltinProvider(name) {
296 names = append(names, name)
297 }
298 }
299 auth.SetExtraAPIKeyProviders(names)
300 }
301 ValidateAndRepairConfig()
302 RefreshModelsAsync()
303}
304
305func zutPack(dir, out string) error {
306 abs, err := filepath.Abs(dir)
307 if err != nil {
308 return err
309 }
310 if _, err := readZutManifest(abs); err != nil {
311 return err
312 }
313 if err := validateZutfileDir(abs); err != nil {
314 return err
315 }
316 if out == "" {
317 m, _ := readZutManifest(abs)
318 base := safeAgentName(m.Name)
319 if base == "" {
320 base = filepath.Base(abs)
321 }
322 out = base + ".zut"
323 }
324 if filepath.Ext(out) != ".zut" {
325 out += ".zut"
326 }
327 outAbs, err := filepath.Abs(out)
328 if err != nil {
329 return err
330 }
331 f, err := os.Create(out)
332 if err != nil {
333 return err
334 }
335 defer f.Close()
336 h := sha256.New()
337 mw := io.MultiWriter(f, h)
338 enc, err := zstd.NewWriter(mw)
339 if err != nil {
340 return err
341 }
342 if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
343 enc.Close()
344 return err
345 }
346 if err := enc.Close(); err != nil {
347 return err
348 }
349 fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
350 return nil
351}
352
353func loadZutfile(ref string) (zutfileLoaded, func(), error) {
354 resolved, err := resolveZutfileRef(ref)
355 if err != nil {
356 return zutfileLoaded{}, nil, err
357 }
358 ref = resolved
359 if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
360 return loadRemoteZutfile(u)
361 }
362 info, err := os.Stat(ref)
363 if err != nil {
364 return zutfileLoaded{}, nil, err
365 }
366 if info.IsDir() {
367 abs, _ := filepath.Abs(ref)
368 m, err := readZutManifest(abs)
369 if err != nil {
370 return zutfileLoaded{}, nil, err
371 }
372 if err := validateZutfileDir(abs); err != nil {
373 return zutfileLoaded{}, nil, err
374 }
375 return zutfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
376 }
377 tmp, err := os.MkdirTemp("", "zutfile-*")
378 if err != nil {
379 return zutfileLoaded{}, nil, err
380 }
381 cleanup := func() { _ = os.RemoveAll(tmp) }
382 digest, err := unpackZutArchive(ref, tmp)
383 if err != nil {
384 cleanup()
385 return zutfileLoaded{}, nil, err
386 }
387 m, err := readZutManifest(tmp)
388 if err != nil {
389 cleanup()
390 return zutfileLoaded{}, nil, err
391 }
392 if err := validateZutfileDir(tmp); err != nil {
393 cleanup()
394 return zutfileLoaded{}, nil, err
395 }
396 return zutfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
397}
398
399func resolveZutfileRef(ref string) (string, error) {
400 if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
401 return ref, nil
402 }
403 if _, err := os.Stat(ref); err == nil {
404 return ref, nil
405 } else if !os.IsNotExist(err) {
406 return "", err
407 }
408 if filepath.Ext(ref) == "" {
409 archive := ref + ".zut"
410 if _, err := os.Stat(archive); err == nil {
411 return archive, nil
412 } else if !os.IsNotExist(err) {
413 return "", err
414 }
415 }
416 if strings.HasSuffix(strings.ToLower(ref), ".zut") {
417 return ref, nil
418 }
419 githubRef := strings.ReplaceAll(ref, "\\", "/")
420 parts := strings.Split(githubRef, "/")
421 if len(parts) == 2 && validGitHubZutfileSegment(parts[0]) && validGitHubZutfileSegment(parts[1]) {
422 return "https://github.com/" + parts[0] + "/" + parts[1], nil
423 }
424 if len(parts) >= 3 {
425 for _, part := range parts {
426 if !validGitHubZutfileSegment(part) {
427 return ref, nil
428 }
429 }
430 return "https://github.com/" + strings.Join(parts, "/"), nil
431 }
432 return ref, nil
433}
434
435func validGitHubZutfileSegment(s string) bool {
436 return s != "" && s == strings.ToLower(s) && safeAgentName(s) == s
437}
438
439var zutfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
440
441var githubArchiveURL = func(owner, repo, ref string) string {
442 return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
443}
444
445func loadRemoteZutfile(u *url.URL) (zutfileLoaded, func(), error) {
446 if !strings.EqualFold(u.Hostname(), "github.com") {
447 return zutfileLoaded{}, nil, fmt.Errorf("unsupported zutfile URL host %q; only github.com agent directories are supported", u.Hostname())
448 }
449 owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
450 if err != nil {
451 return zutfileLoaded{}, nil, err
452 }
453 tmp, err := os.MkdirTemp("", "zutfile-github-*")
454 if err != nil {
455 return zutfileLoaded{}, nil, err
456 }
457 cleanup := func() { _ = os.RemoveAll(tmp) }
458 if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
459 cleanup()
460 return zutfileLoaded{}, nil, err
461 }
462 root, err := singleExtractedRoot(tmp)
463 if err != nil {
464 cleanup()
465 return zutfileLoaded{}, nil, err
466 }
467 dir := filepath.Join(root, filepath.FromSlash(subdir))
468 if subdir == "" {
469 dir = root
470 }
471 if !pathWithin(root, dir) {
472 cleanup()
473 return zutfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
474 }
475 m, err := readZutManifest(dir)
476 if err != nil {
477 cleanup()
478 return zutfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
479 }
480 if err := validateZutfileDir(dir); err != nil {
481 cleanup()
482 return zutfileLoaded{}, nil, err
483 }
484 return zutfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
485}
486
487func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
488 parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
489 for i := range parts {
490 parts[i], err = url.PathUnescape(parts[i])
491 if err != nil {
492 return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
493 }
494 }
495 if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
496 return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
497 }
498 owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
499 if len(parts) >= 4 && parts[2] == "tree" {
500 ref = parts[3]
501 subdir = strings.Join(parts[4:], "/")
502 } else {
503 subdir = strings.Join(parts[2:], "/")
504 }
505 if repo == "" || ref == "" {
506 return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
507 }
508 clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
509 if clean == "." {
510 clean = ""
511 }
512 if clean == ".." || strings.HasPrefix(clean, "../") {
513 return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
514 }
515 return owner, repo, ref, clean, nil
516}
517
518func downloadGitHubArchive(archiveURL, dst string) error {
519 req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
520 if err != nil {
521 return err
522 }
523 req.Header.Set("User-Agent", "zut")
524 resp, err := zutfileHTTPClient.Do(req)
525 if err != nil {
526 return fmt.Errorf("download GitHub repository: %w", err)
527 }
528 defer resp.Body.Close()
529 if resp.StatusCode != http.StatusOK {
530 return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
531 }
532 limited := &io.LimitedReader{R: resp.Body, N: maxZutfileCompressedSize + 1}
533 gr, err := gzip.NewReader(limited)
534 if err != nil {
535 return fmt.Errorf("read GitHub archive: %w", err)
536 }
537 defer gr.Close()
538 if err := untar(gr, dst); err != nil {
539 return fmt.Errorf("extract GitHub archive: %w", err)
540 }
541 if limited.N <= 0 {
542 return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
543 }
544 return nil
545}
546
547func pathWithin(root, target string) bool {
548 rel, err := filepath.Rel(root, target)
549 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
550}
551
552func singleExtractedRoot(dir string) (string, error) {
553 entries, err := os.ReadDir(dir)
554 if err != nil {
555 return "", err
556 }
557 if len(entries) != 1 || !entries[0].IsDir() {
558 return "", fmt.Errorf("GitHub archive has an unexpected layout")
559 }
560 return filepath.Join(dir, entries[0].Name()), nil
561}
562
563func readZutManifest(dir string) (ZutfileManifest, error) {
564 var m ZutfileManifest
565 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
566 if err != nil {
567 return m, fmt.Errorf("manifest.json is required: %w", err)
568 }
569 if err := json.Unmarshal(b, &m); err != nil {
570 return m, fmt.Errorf("manifest.json: %w", err)
571 }
572 if m.Zutfile != 1 {
573 return m, fmt.Errorf("unsupported zutfile version %d", m.Zutfile)
574 }
575 name := strings.TrimSpace(m.Name)
576 if name == "" {
577 return m, fmt.Errorf("manifest name is required")
578 }
579 if name != strings.ToLower(name) || safeAgentName(name) != name {
580 return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
581 }
582 if len(m.Permissions.Net.Allow) > 0 {
583 return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
584 }
585 if len(m.Permissions.Env.Read) > 0 {
586 return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
587 }
588 mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
589 if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
590 return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
591 }
592 if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
593 return m, fmt.Errorf("bash allowlist mode requires at least one command")
594 }
595 return m, nil
596}
597
598func validateZutfileDir(dir string) error {
599 agentPath := filepath.Join(dir, "AGENT.md")
600 st, err := os.Stat(agentPath)
601 switch {
602 case err == nil:
603 if !st.Mode().IsRegular() {
604 return fmt.Errorf("AGENT.md must be a regular file")
605 }
606 case os.IsNotExist(err):
607 // AGENT.md is optional.
608 default:
609 return fmt.Errorf("stat AGENT.md: %w", err)
610 }
611 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
612 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
613 }
614 return nil
615}
616
617const (
618 maxZutfileCompressedSize = 100 << 20
619 maxZutfileEntrySize = 64 << 20
620 maxZutfileExpandedSize = 256 << 20
621)
622
623func unpackZutArchive(path, dst string) (string, error) {
624 info, err := os.Stat(path)
625 if err != nil {
626 return "", err
627 }
628 if info.Size() > maxZutfileCompressedSize {
629 return "", fmt.Errorf("zutfile exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
630 }
631 b, err := os.ReadFile(path)
632 if err != nil {
633 return "", err
634 }
635 digest := sha256.Sum256(b)
636 r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZutfileExpandedSize))
637 if err != nil {
638 // Development fallback for older experiments.
639 gr, gerr := gzip.NewReader(bytes.NewReader(b))
640 if gerr != nil {
641 return "", err
642 }
643 defer gr.Close()
644 return hex.EncodeToString(digest[:]), untar(gr, dst)
645 }
646 defer r.Close()
647 return hex.EncodeToString(digest[:]), untar(r, dst)
648}
649
650func untar(r io.Reader, dst string) error {
651 tr := tar.NewReader(r)
652 var expanded int64
653 for {
654 h, err := tr.Next()
655 if err == io.EOF {
656 return nil
657 }
658 if err != nil {
659 return err
660 }
661 name := filepath.Clean(h.Name)
662 if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
663 return fmt.Errorf("unsafe path in archive: %s", h.Name)
664 }
665 path := filepath.Join(dst, name)
666 if h.Size < 0 || h.Size > maxZutfileEntrySize || expanded+h.Size > maxZutfileExpandedSize {
667 return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
668 }
669 expanded += h.Size
670 switch h.Typeflag {
671 case tar.TypeDir:
672 if err := os.MkdirAll(path, 0o755); err != nil {
673 return err
674 }
675 case tar.TypeReg:
676 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
677 return err
678 }
679 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
680 if err != nil {
681 return err
682 }
683 _, cerr := io.Copy(f, tr)
684 if err := f.Close(); err != nil && cerr == nil {
685 cerr = err
686 }
687 if cerr != nil {
688 return cerr
689 }
690 }
691 }
692}
693
694func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
695 excluded := map[string]bool{}
696 for _, p := range exclude {
697 if p == "" {
698 continue
699 }
700 abs, err := filepath.Abs(p)
701 if err == nil {
702 excluded[abs] = true
703 }
704 }
705 var files []string
706 if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
707 if err != nil || path == root {
708 return err
709 }
710 absPath, _ := filepath.Abs(path)
711 if excluded[absPath] {
712 return nil
713 }
714 if d.Type()&os.ModeSymlink != 0 {
715 return fmt.Errorf("symlinks are not supported in zutfiles: %s", path)
716 }
717 files = append(files, path)
718 return nil
719 }); err != nil {
720 return err
721 }
722 sort.Strings(files)
723 tw := tar.NewWriter(w)
724 defer tw.Close()
725 fixed := time.Unix(0, 0)
726 for _, path := range files {
727 info, err := os.Stat(path)
728 if err != nil {
729 return err
730 }
731 rel, _ := filepath.Rel(root, path)
732 rel = filepath.ToSlash(rel)
733 h, err := tar.FileInfoHeader(info, "")
734 if err != nil {
735 return err
736 }
737 h.Name = rel
738 h.ModTime = fixed
739 h.AccessTime = fixed
740 h.ChangeTime = fixed
741 h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
742 if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
743 h.Name += "/"
744 }
745 if err := tw.WriteHeader(h); err != nil {
746 return err
747 }
748 if info.Mode().IsRegular() {
749 f, err := os.Open(path)
750 if err != nil {
751 return err
752 }
753 _, cerr := io.Copy(tw, f)
754 _ = f.Close()
755 if cerr != nil {
756 return cerr
757 }
758 }
759 }
760 return nil
761}
762
763func checkZutfileRequirements(zf zutfileLoaded, version string) error {
764 if min := strings.TrimSpace(zf.Manifest.Runtime.MinZut); min != "" {
765 if isDevVersion(version) {
766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
767 }
768 comparison, err := compareVersions(version, min)
769 if err != nil {
770 return fmt.Errorf("validate min_zut requirement: %w", err)
771 }
772 if comparison < 0 {
773 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
774 }
775 }
776 if len(zf.Manifest.Requirements.OS) > 0 {
777 ok := false
778 for _, osName := range zf.Manifest.Requirements.OS {
779 if osName == runtime.GOOS {
780 ok = true
781 break
782 }
783 }
784 if !ok {
785 return fmt.Errorf("agent does not support %s", runtime.GOOS)
786 }
787 }
788 if len(zf.Manifest.Requirements.Bin) > 0 {
789 for _, b := range zf.Manifest.Requirements.Bin {
790 if _, err := execLookPath(b); err != nil {
791 return fmt.Errorf("agent requires binary %q", b)
792 }
793 }
794 }
795 return nil
796}
797
798var execLookPath = exec.LookPath
799
800func peelLeadingYes(args []string) (yes bool, rest []string) {
801 rest = args
802 for len(rest) > 0 {
803 switch rest[0] {
804 case "-y", "--yes":
805 yes = true
806 rest = rest[1:]
807 default:
808 return yes, rest
809 }
810 }
811 return yes, rest
812}
813
814func consentZutfile(zf zutfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
815 if autoYes || os.Getenv("ZUT_AGENT_CONSENT") == "1" {
816 return acceptZutfileConsent(zf, perms)
817 }
818 // "ask" deliberately requires approval on every launch. Other consent
819 // is durable only for this exact artifact digest, so any package change
820 // causes a fresh prompt.
821 consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
822 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
823 if _, err := os.Stat(consentPath); err == nil {
824 return true, nil
825 }
826 }
827 interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
828 th := tui.Dark
829 if interactive {
830 cfg, _ := LoadConfig()
831 th, _, _ = tui.DetectThemeWithCustom(ZutHome(), cfg.Theme, 80*time.Millisecond)
832 }
833 fmt.Print(formatZutfileConsent(zf, perms, th, interactive))
834 if !interactive {
835 return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZUT_AGENT_CONSENT=1 to allow")
836 }
837 allowed, err := readZutfileConsent(os.Stdin, os.Stdout, th)
838 if err != nil || !allowed {
839 return allowed, err
840 }
841 return acceptZutfileConsent(zf, perms)
842}
843
844// acceptZutfileConsent records durable consent when the bash mode is not
845// "ask". Ask mode never caches receipts so every launch re-prompts unless
846// -y / ZUT_AGENT_CONSENT is used for that run.
847func acceptZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet) (bool, error) {
848 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
849 return true, nil
850 }
851 consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
852 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
853 return false, fmt.Errorf("save agent consent: %w", err)
854 }
855 receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
856 data, _ := json.MarshalIndent(receipt, "", " ")
857 if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
858 return false, fmt.Errorf("save agent consent: %w", err)
859 }
860 return true, nil
861}
862
863var errZutfileConsentInterrupted = errors.New("interrupted")
864
865func formatZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
866 style := func(c tui.TerminalColor, text string) string {
867 if !color {
868 return text
869 }
870 return th.FGColor(c, text)
871 }
872
873 var sb strings.Builder
874 sb.WriteString(style(th.Assistant, "Agent"))
875 sb.WriteByte(' ')
876 sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version))
877 sb.WriteString(style(th.FG, " wants to run."))
878 sb.WriteString("\n\n")
879 sb.WriteString(permissionSummaryStyled(perms, th, color))
880 if color {
881 sb.WriteByte('\n')
882 sb.WriteString(style(th.Assistant, "Allow?"))
883 sb.WriteByte(' ')
884 sb.WriteString(style(th.Muted, "[y/n]"))
885 sb.WriteByte(' ')
886 }
887 return sb.String()
888}
889
890// readZutfileConsent accepts one y/n answer followed by Enter without echoing
891// unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can
892// be restored before the command aborts.
893func readZutfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) {
894 fd := int(in.Fd())
895 state, err := term.MakeRaw(fd)
896 if err != nil {
897 return false, fmt.Errorf("read agent consent: %w", err)
898 }
899 defer func() { _ = term.Restore(fd, state) }()
900
901 answer, err := readZutfileConsentKey(in, func(answer byte) {
902 if answer == 0 {
903 fmt.Fprint(out, "\b \b")
904 return
905 }
906 fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
907 })
908 switch {
909 case errors.Is(err, errZutfileConsentInterrupted):
910 fmt.Fprint(out, "^C\r\n")
911 return false, err
912 case err != nil:
913 fmt.Fprint(out, "\r\n")
914 return false, fmt.Errorf("read agent consent: %w", err)
915 default:
916 fmt.Fprint(out, "\r\n")
917 return answer == 'y', nil
918 }
919}
920
921func readZutfileConsentKey(r io.Reader, echo func(byte)) (byte, error) {
922 var answer byte
923 var b [1]byte
924 for {
925 if _, err := io.ReadFull(r, b[:]); err != nil {
926 return 0, err
927 }
928 switch b[0] {
929 case 'y', 'Y', 'n', 'N':
930 if answer == 0 {
931 answer = b[0] | 0x20
932 if echo != nil {
933 echo(answer)
934 }
935 }
936 case '\r', '\n':
937 if answer != 0 {
938 return answer, nil
939 }
940 case '\b', 0x7f:
941 if answer != 0 {
942 answer = 0
943 if echo != nil {
944 echo(0)
945 }
946 }
947 case 3: // Ctrl+C in raw mode.
948 return 0, errZutfileConsentInterrupted
949 }
950 }
951}
952
953func permissionSummary(p tools.PermissionSet) string {
954 return permissionSummaryStyled(p, tui.Theme{}, false)
955}
956
957func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
958 style := func(c tui.TerminalColor, text string) string {
959 if !color {
960 return text
961 }
962 return th.FGColor(c, text)
963 }
964 permissionValue := func(value string) string { return style(th.FG, value) }
965
966 var sb strings.Builder
967 sb.WriteString(style(th.Muted, " fs read: "))
968 if len(p.FS.Read) > 0 {
969 sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", ")))
970 } else {
971 sb.WriteString(style(th.Muted, "none"))
972 }
973 sb.WriteByte('\n')
974 sb.WriteString(style(th.Muted, " fs write: "))
975 if len(p.FS.Write) > 0 {
976 sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", ")))
977 } else {
978 sb.WriteString(style(th.Muted, "none"))
979 }
980 sb.WriteByte('\n')
981 mode := p.Bash.Mode
982 if mode == "" {
983 mode = "none"
984 }
985 sb.WriteString(style(th.Muted, " bash: "))
986 modeColor := th.FG
987 if mode == "ask" {
988 modeColor = th.Warning
989 }
990 sb.WriteString(style(modeColor, mode))
991 if len(p.Bash.Allow) > 0 {
992 sb.WriteString(style(th.Muted, " ("))
993 sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", ")))
994 sb.WriteString(style(th.Muted, ")"))
995 }
996 sb.WriteByte('\n')
997 if len(p.Net.Allow) > 0 {
998 sb.WriteString(style(th.Muted, " net: "))
999 sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", ")))
1000 sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
1001 }
1002 if len(p.Env.Read) > 0 {
1003 sb.WriteString(style(th.Muted, " env read: "))
1004 sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", ")))
1005 sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
1006 }
1007 return sb.String()
1008}
1009
1010func bundledExtensionDirs(root string) []string {
1011 entries, err := os.ReadDir(root)
1012 if err != nil {
1013 return nil
1014 }
1015 var out []string
1016 for _, e := range entries {
1017 if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
1018 out = append(out, filepath.Join(root, e.Name()))
1019 }
1020 }
1021 return out
1022}
1023
1024func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
1025func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
1026
1027func safeAgentName(s string) string {
1028 s = strings.TrimSpace(strings.ToLower(s))
1029 s = strings.ReplaceAll(s, "/", "_")
1030 s = strings.ReplaceAll(s, "\\", "_")
1031 s = strings.Map(func(r rune) rune {
1032 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
1033 return r
1034 }
1035 return '-'
1036 }, s)
1037 return strings.Trim(s, "-._")
1038}
1039
1040func digestDirectory(dir string) string {
1041 h := sha256.New()
1042 _ = writeCanonicalTar(dir, h)
1043 return hex.EncodeToString(h.Sum(nil))
1044}