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