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