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