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