v0.3.49 2470fda

zotfile.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close