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