v0.2.87 c98b3d2

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

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 477

↑↓ moveenter openesc close