v0.9.1 406ddf4

zutfile.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/bnema/zut/packages/agent/tools"
24	"github.com/bnema/zut/packages/provider"
25	"github.com/bnema/zut/packages/provider/auth"
26	"github.com/bnema/zut/packages/tui"
27	"github.com/klauspost/compress/zstd"
28	"golang.org/x/term"
29)
30
31type ZutfileManifest struct {
32	Zutfile     int    `json:"zutfile"`
33	Name        string `json:"name"`
34	Version     string `json:"version"`
35	Description string `json:"description"`
36	License     string `json:"license"`
37	Runtime     struct {
38		MinZut string `json:"min_zut"`
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 zutfileLoaded struct {
60	Dir      string
61	Temp     bool
62	Digest   string
63	Manifest ZutfileManifest
64}
65
66func runZutfileCommand(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, zutPack(dir, out)
81	case "inspect":
82		if len(rawArgs) < 2 {
83			return true, fmt.Errorf("zut inspect requires a name, .zut file, directory, or GitHub URL")
84		}
85		return true, zutInspect(rawArgs[1])
86	case "verify":
87		if len(rawArgs) < 2 {
88			return true, fmt.Errorf("zut verify requires a name, .zut file, directory, or GitHub URL")
89		}
90		zf, cleanup, err := loadZutfile(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("zut run requires a name, .zut file, directory, or GitHub URL")
102		}
103		yes, remaining := peelLeadingYes(rawArgs[1:])
104		if len(remaining) < 1 {
105			return true, fmt.Errorf("zut run requires a name, .zut 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, runLocalZutfile(ref, args, version)
118	default:
119		return false, nil
120	}
121}
122
123func runLocalZutfile(ref string, args Args, version string) error {
124	prepareRuntimeCatalog()
125	zf, cleanup, err := loadZutfile(ref)
126	if cleanup != nil {
127		defer cleanup()
128	}
129	if err != nil {
130		return err
131	}
132	if err := checkZutfileRequirements(zf, version); err != nil {
133		return err
134	}
135	agentData := filepath.Join(ZutHome(), "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 := consentZutfile(zf, perms, args.Yes)
141	if err != nil {
142		return err
143	}
144	if !allowed {
145		return nil
146	}
147	if err := applyZutfileModelRequirements(&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("ZUT_AGENT_SKILLS")
171		v := filepath.Join(zf.Dir, "skills")
172		if old != "" {
173			v += string(os.PathListSeparator) + old
174		}
175		_ = os.Setenv("ZUT_AGENT_SKILLS", v)
176		defer os.Setenv("ZUT_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 zutInspect(ref string) error {
185	zf, cleanup, err := loadZutfile(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 applyZutfileModelRequirements(args *Args, m ZutfileManifest) 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 zut 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 zutPack(dir, out string) error {
293	abs, err := filepath.Abs(dir)
294	if err != nil {
295		return err
296	}
297	if _, err := readZutManifest(abs); err != nil {
298		return err
299	}
300	if err := validateZutfileDir(abs); err != nil {
301		return err
302	}
303	if out == "" {
304		m, _ := readZutManifest(abs)
305		base := safeAgentName(m.Name)
306		if base == "" {
307			base = filepath.Base(abs)
308		}
309		out = base + ".zut"
310	}
311	if filepath.Ext(out) != ".zut" {
312		out += ".zut"
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 loadZutfile(ref string) (zutfileLoaded, func(), error) {
341	resolved, err := resolveZutfileRef(ref)
342	if err != nil {
343		return zutfileLoaded{}, nil, err
344	}
345	ref = resolved
346	if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
347		return loadRemoteZutfile(u)
348	}
349	info, err := os.Stat(ref)
350	if err != nil {
351		return zutfileLoaded{}, nil, err
352	}
353	if info.IsDir() {
354		abs, _ := filepath.Abs(ref)
355		m, err := readZutManifest(abs)
356		if err != nil {
357			return zutfileLoaded{}, nil, err
358		}
359		if err := validateZutfileDir(abs); err != nil {
360			return zutfileLoaded{}, nil, err
361		}
362		return zutfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
363	}
364	tmp, err := os.MkdirTemp("", "zutfile-*")
365	if err != nil {
366		return zutfileLoaded{}, nil, err
367	}
368	cleanup := func() { _ = os.RemoveAll(tmp) }
369	digest, err := unpackZutArchive(ref, tmp)
370	if err != nil {
371		cleanup()
372		return zutfileLoaded{}, nil, err
373	}
374	m, err := readZutManifest(tmp)
375	if err != nil {
376		cleanup()
377		return zutfileLoaded{}, nil, err
378	}
379	if err := validateZutfileDir(tmp); err != nil {
380		cleanup()
381		return zutfileLoaded{}, nil, err
382	}
383	return zutfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
384}
385
386func resolveZutfileRef(ref string) (string, error) {
387	if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
388		return ref, nil
389	}
390	if _, err := os.Stat(ref); err == nil {
391		return ref, nil
392	} else if !os.IsNotExist(err) {
393		return "", err
394	}
395	if filepath.Ext(ref) == "" {
396		archive := ref + ".zut"
397		if _, err := os.Stat(archive); err == nil {
398			return archive, nil
399		} else if !os.IsNotExist(err) {
400			return "", err
401		}
402	}
403	if strings.HasSuffix(strings.ToLower(ref), ".zut") {
404		return ref, nil
405	}
406	githubRef := strings.ReplaceAll(ref, "\\", "/")
407	parts := strings.Split(githubRef, "/")
408	if len(parts) == 2 && validGitHubZutfileSegment(parts[0]) && validGitHubZutfileSegment(parts[1]) {
409		return "https://github.com/" + parts[0] + "/" + parts[1], nil
410	}
411	if len(parts) >= 3 {
412		for _, part := range parts {
413			if !validGitHubZutfileSegment(part) {
414				return ref, nil
415			}
416		}
417		return "https://github.com/" + strings.Join(parts, "/"), nil
418	}
419	return ref, nil
420}
421
422func validGitHubZutfileSegment(s string) bool {
423	return s != "" && s == strings.ToLower(s) && safeAgentName(s) == s
424}
425
426var zutfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
427
428var githubArchiveURL = func(owner, repo, ref string) string {
429	return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
430}
431
432func loadRemoteZutfile(u *url.URL) (zutfileLoaded, func(), error) {
433	if !strings.EqualFold(u.Hostname(), "github.com") {
434		return zutfileLoaded{}, nil, fmt.Errorf("unsupported zutfile URL host %q; only github.com agent directories are supported", u.Hostname())
435	}
436	owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
437	if err != nil {
438		return zutfileLoaded{}, nil, err
439	}
440	tmp, err := os.MkdirTemp("", "zutfile-github-*")
441	if err != nil {
442		return zutfileLoaded{}, nil, err
443	}
444	cleanup := func() { _ = os.RemoveAll(tmp) }
445	if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
446		cleanup()
447		return zutfileLoaded{}, nil, err
448	}
449	root, err := singleExtractedRoot(tmp)
450	if err != nil {
451		cleanup()
452		return zutfileLoaded{}, nil, err
453	}
454	dir := filepath.Join(root, filepath.FromSlash(subdir))
455	if subdir == "" {
456		dir = root
457	}
458	if !pathWithin(root, dir) {
459		cleanup()
460		return zutfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
461	}
462	m, err := readZutManifest(dir)
463	if err != nil {
464		cleanup()
465		return zutfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
466	}
467	if err := validateZutfileDir(dir); err != nil {
468		cleanup()
469		return zutfileLoaded{}, nil, err
470	}
471	return zutfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
472}
473
474func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
475	parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
476	for i := range parts {
477		parts[i], err = url.PathUnescape(parts[i])
478		if err != nil {
479			return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
480		}
481	}
482	if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
483		return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
484	}
485	owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
486	if len(parts) >= 4 && parts[2] == "tree" {
487		ref = parts[3]
488		subdir = strings.Join(parts[4:], "/")
489	} else {
490		subdir = strings.Join(parts[2:], "/")
491	}
492	if repo == "" || ref == "" {
493		return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
494	}
495	clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
496	if clean == "." {
497		clean = ""
498	}
499	if clean == ".." || strings.HasPrefix(clean, "../") {
500		return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
501	}
502	return owner, repo, ref, clean, nil
503}
504
505func downloadGitHubArchive(archiveURL, dst string) error {
506	req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
507	if err != nil {
508		return err
509	}
510	req.Header.Set("User-Agent", "zut")
511	resp, err := zutfileHTTPClient.Do(req)
512	if err != nil {
513		return fmt.Errorf("download GitHub repository: %w", err)
514	}
515	defer resp.Body.Close()
516	if resp.StatusCode != http.StatusOK {
517		return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
518	}
519	limited := &io.LimitedReader{R: resp.Body, N: maxZutfileCompressedSize + 1}
520	gr, err := gzip.NewReader(limited)
521	if err != nil {
522		return fmt.Errorf("read GitHub archive: %w", err)
523	}
524	defer gr.Close()
525	if err := untar(gr, dst); err != nil {
526		return fmt.Errorf("extract GitHub archive: %w", err)
527	}
528	if limited.N <= 0 {
529		return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
530	}
531	return nil
532}
533
534func pathWithin(root, target string) bool {
535	rel, err := filepath.Rel(root, target)
536	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
537}
538
539func singleExtractedRoot(dir string) (string, error) {
540	entries, err := os.ReadDir(dir)
541	if err != nil {
542		return "", err
543	}
544	if len(entries) != 1 || !entries[0].IsDir() {
545		return "", fmt.Errorf("GitHub archive has an unexpected layout")
546	}
547	return filepath.Join(dir, entries[0].Name()), nil
548}
549
550func readZutManifest(dir string) (ZutfileManifest, error) {
551	var m ZutfileManifest
552	b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
553	if err != nil {
554		return m, fmt.Errorf("manifest.json is required: %w", err)
555	}
556	if err := json.Unmarshal(b, &m); err != nil {
557		return m, fmt.Errorf("manifest.json: %w", err)
558	}
559	if m.Zutfile != 1 {
560		return m, fmt.Errorf("unsupported zutfile version %d", m.Zutfile)
561	}
562	name := strings.TrimSpace(m.Name)
563	if name == "" {
564		return m, fmt.Errorf("manifest name is required")
565	}
566	if name != strings.ToLower(name) || safeAgentName(name) != name {
567		return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
568	}
569	if len(m.Permissions.Net.Allow) > 0 {
570		return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
571	}
572	if len(m.Permissions.Env.Read) > 0 {
573		return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
574	}
575	mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
576	if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
577		return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
578	}
579	if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
580		return m, fmt.Errorf("bash allowlist mode requires at least one command")
581	}
582	return m, nil
583}
584
585func validateZutfileDir(dir string) error {
586	agentPath := filepath.Join(dir, "AGENT.md")
587	st, err := os.Stat(agentPath)
588	switch {
589	case err == nil:
590		if !st.Mode().IsRegular() {
591			return fmt.Errorf("AGENT.md must be a regular file")
592		}
593	case os.IsNotExist(err):
594		// AGENT.md is optional.
595	default:
596		return fmt.Errorf("stat AGENT.md: %w", err)
597	}
598	if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
599		return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
600	}
601	return nil
602}
603
604const (
605	maxZutfileCompressedSize = 100 << 20
606	maxZutfileEntrySize      = 64 << 20
607	maxZutfileExpandedSize   = 256 << 20
608)
609
610func unpackZutArchive(path, dst string) (string, error) {
611	info, err := os.Stat(path)
612	if err != nil {
613		return "", err
614	}
615	if info.Size() > maxZutfileCompressedSize {
616		return "", fmt.Errorf("zutfile exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
617	}
618	b, err := os.ReadFile(path)
619	if err != nil {
620		return "", err
621	}
622	digest := sha256.Sum256(b)
623	r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZutfileExpandedSize))
624	if err != nil {
625		// Development fallback for older experiments.
626		gr, gerr := gzip.NewReader(bytes.NewReader(b))
627		if gerr != nil {
628			return "", err
629		}
630		defer gr.Close()
631		return hex.EncodeToString(digest[:]), untar(gr, dst)
632	}
633	defer r.Close()
634	return hex.EncodeToString(digest[:]), untar(r, dst)
635}
636
637func untar(r io.Reader, dst string) error {
638	tr := tar.NewReader(r)
639	var expanded int64
640	for {
641		h, err := tr.Next()
642		if err == io.EOF {
643			return nil
644		}
645		if err != nil {
646			return err
647		}
648		name := filepath.Clean(h.Name)
649		if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
650			return fmt.Errorf("unsafe path in archive: %s", h.Name)
651		}
652		path := filepath.Join(dst, name)
653		if h.Size < 0 || h.Size > maxZutfileEntrySize || expanded+h.Size > maxZutfileExpandedSize {
654			return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
655		}
656		expanded += h.Size
657		switch h.Typeflag {
658		case tar.TypeDir:
659			if err := os.MkdirAll(path, 0o755); err != nil {
660				return err
661			}
662		case tar.TypeReg:
663			if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
664				return err
665			}
666			f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
667			if err != nil {
668				return err
669			}
670			_, cerr := io.Copy(f, tr)
671			if err := f.Close(); err != nil && cerr == nil {
672				cerr = err
673			}
674			if cerr != nil {
675				return cerr
676			}
677		}
678	}
679}
680
681func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
682	excluded := map[string]bool{}
683	for _, p := range exclude {
684		if p == "" {
685			continue
686		}
687		abs, err := filepath.Abs(p)
688		if err == nil {
689			excluded[abs] = true
690		}
691	}
692	var files []string
693	if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
694		if err != nil || path == root {
695			return err
696		}
697		absPath, _ := filepath.Abs(path)
698		if excluded[absPath] {
699			return nil
700		}
701		if d.Type()&os.ModeSymlink != 0 {
702			return fmt.Errorf("symlinks are not supported in zutfiles: %s", path)
703		}
704		files = append(files, path)
705		return nil
706	}); err != nil {
707		return err
708	}
709	sort.Strings(files)
710	tw := tar.NewWriter(w)
711	defer tw.Close()
712	fixed := time.Unix(0, 0)
713	for _, path := range files {
714		info, err := os.Stat(path)
715		if err != nil {
716			return err
717		}
718		rel, _ := filepath.Rel(root, path)
719		rel = filepath.ToSlash(rel)
720		h, err := tar.FileInfoHeader(info, "")
721		if err != nil {
722			return err
723		}
724		h.Name = rel
725		h.ModTime = fixed
726		h.AccessTime = fixed
727		h.ChangeTime = fixed
728		h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
729		if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
730			h.Name += "/"
731		}
732		if err := tw.WriteHeader(h); err != nil {
733			return err
734		}
735		if info.Mode().IsRegular() {
736			f, err := os.Open(path)
737			if err != nil {
738				return err
739			}
740			_, cerr := io.Copy(tw, f)
741			_ = f.Close()
742			if cerr != nil {
743				return cerr
744			}
745		}
746	}
747	return nil
748}
749
750func checkZutfileRequirements(zf zutfileLoaded, version string) error {
751	if min := strings.TrimSpace(zf.Manifest.Runtime.MinZut); min != "" {
752		if isDevVersion(version) {
753			return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
754		}
755		if versionLess(version, min) {
756			return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
757		}
758	}
759	if len(zf.Manifest.Requirements.OS) > 0 {
760		ok := false
761		for _, osName := range zf.Manifest.Requirements.OS {
762			if osName == runtime.GOOS {
763				ok = true
764				break
765			}
766		}
767		if !ok {
768			return fmt.Errorf("agent does not support %s", runtime.GOOS)
769		}
770	}
771	if len(zf.Manifest.Requirements.Bin) > 0 {
772		for _, b := range zf.Manifest.Requirements.Bin {
773			if _, err := execLookPath(b); err != nil {
774				return fmt.Errorf("agent requires binary %q", b)
775			}
776		}
777	}
778	return nil
779}
780
781var execLookPath = exec.LookPath
782
783func peelLeadingYes(args []string) (yes bool, rest []string) {
784	rest = args
785	for len(rest) > 0 {
786		switch rest[0] {
787		case "-y", "--yes":
788			yes = true
789			rest = rest[1:]
790		default:
791			return yes, rest
792		}
793	}
794	return yes, rest
795}
796
797func consentZutfile(zf zutfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
798	if autoYes || os.Getenv("ZUT_AGENT_CONSENT") == "1" {
799		return acceptZutfileConsent(zf, perms)
800	}
801	// "ask" deliberately requires approval on every launch. Other consent
802	// is durable only for this exact artifact digest, so any package change
803	// causes a fresh prompt.
804	consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
805	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
806		if _, err := os.Stat(consentPath); err == nil {
807			return true, nil
808		}
809	}
810	interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
811	th := tui.Dark
812	if interactive {
813		cfg, _ := LoadConfig()
814		th, _, _ = tui.DetectThemeWithCustom(ZutHome(), cfg.Theme, 80*time.Millisecond)
815	}
816	fmt.Print(formatZutfileConsent(zf, perms, th, interactive))
817	if !interactive {
818		return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZUT_AGENT_CONSENT=1 to allow")
819	}
820	allowed, err := readZutfileConsent(os.Stdin, os.Stdout, th)
821	if err != nil || !allowed {
822		return allowed, err
823	}
824	return acceptZutfileConsent(zf, perms)
825}
826
827// acceptZutfileConsent records durable consent when the bash mode is not
828// "ask". Ask mode never caches receipts so every launch re-prompts unless
829// -y / ZUT_AGENT_CONSENT is used for that run.
830func acceptZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet) (bool, error) {
831	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
832		return true, nil
833	}
834	consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
835	if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
836		return false, fmt.Errorf("save agent consent: %w", err)
837	}
838	receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
839	data, _ := json.MarshalIndent(receipt, "", "  ")
840	if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
841		return false, fmt.Errorf("save agent consent: %w", err)
842	}
843	return true, nil
844}
845
846var errZutfileConsentInterrupted = errors.New("interrupted")
847
848func formatZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
849	style := func(c tui.TerminalColor, text string) string {
850		if !color {
851			return text
852		}
853		return th.FGColor(c, text)
854	}
855
856	var sb strings.Builder
857	sb.WriteString(style(th.Assistant, "Agent"))
858	sb.WriteByte(' ')
859	sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version))
860	sb.WriteString(style(th.FG, " wants to run."))
861	sb.WriteString("\n\n")
862	sb.WriteString(permissionSummaryStyled(perms, th, color))
863	if color {
864		sb.WriteByte('\n')
865		sb.WriteString(style(th.Assistant, "Allow?"))
866		sb.WriteByte(' ')
867		sb.WriteString(style(th.Muted, "[y/n]"))
868		sb.WriteByte(' ')
869	}
870	return sb.String()
871}
872
873// readZutfileConsent accepts one y/n answer followed by Enter without echoing
874// unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can
875// be restored before the command aborts.
876func readZutfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) {
877	fd := int(in.Fd())
878	state, err := term.MakeRaw(fd)
879	if err != nil {
880		return false, fmt.Errorf("read agent consent: %w", err)
881	}
882	defer func() { _ = term.Restore(fd, state) }()
883
884	answer, err := readZutfileConsentKey(in, func(answer byte) {
885		if answer == 0 {
886			fmt.Fprint(out, "\b \b")
887			return
888		}
889		fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
890	})
891	switch {
892	case errors.Is(err, errZutfileConsentInterrupted):
893		fmt.Fprint(out, "^C\r\n")
894		return false, err
895	case err != nil:
896		fmt.Fprint(out, "\r\n")
897		return false, fmt.Errorf("read agent consent: %w", err)
898	default:
899		fmt.Fprint(out, "\r\n")
900		return answer == 'y', nil
901	}
902}
903
904func readZutfileConsentKey(r io.Reader, echo func(byte)) (byte, error) {
905	var answer byte
906	var b [1]byte
907	for {
908		if _, err := io.ReadFull(r, b[:]); err != nil {
909			return 0, err
910		}
911		switch b[0] {
912		case 'y', 'Y', 'n', 'N':
913			if answer == 0 {
914				answer = b[0] | 0x20
915				if echo != nil {
916					echo(answer)
917				}
918			}
919		case '\r', '\n':
920			if answer != 0 {
921				return answer, nil
922			}
923		case '\b', 0x7f:
924			if answer != 0 {
925				answer = 0
926				if echo != nil {
927					echo(0)
928				}
929			}
930		case 3: // Ctrl+C in raw mode.
931			return 0, errZutfileConsentInterrupted
932		}
933	}
934}
935
936func permissionSummary(p tools.PermissionSet) string {
937	return permissionSummaryStyled(p, tui.Theme{}, false)
938}
939
940func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
941	style := func(c tui.TerminalColor, text string) string {
942		if !color {
943			return text
944		}
945		return th.FGColor(c, text)
946	}
947	permissionValue := func(value string) string { return style(th.FG, value) }
948
949	var sb strings.Builder
950	sb.WriteString(style(th.Muted, "  fs read: "))
951	if len(p.FS.Read) > 0 {
952		sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", ")))
953	} else {
954		sb.WriteString(style(th.Muted, "none"))
955	}
956	sb.WriteByte('\n')
957	sb.WriteString(style(th.Muted, "  fs write: "))
958	if len(p.FS.Write) > 0 {
959		sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", ")))
960	} else {
961		sb.WriteString(style(th.Muted, "none"))
962	}
963	sb.WriteByte('\n')
964	mode := p.Bash.Mode
965	if mode == "" {
966		mode = "none"
967	}
968	sb.WriteString(style(th.Muted, "  bash: "))
969	modeColor := th.FG
970	if mode == "ask" {
971		modeColor = th.Warning
972	}
973	sb.WriteString(style(modeColor, mode))
974	if len(p.Bash.Allow) > 0 {
975		sb.WriteString(style(th.Muted, " ("))
976		sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", ")))
977		sb.WriteString(style(th.Muted, ")"))
978	}
979	sb.WriteByte('\n')
980	if len(p.Net.Allow) > 0 {
981		sb.WriteString(style(th.Muted, "  net: "))
982		sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", ")))
983		sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
984	}
985	if len(p.Env.Read) > 0 {
986		sb.WriteString(style(th.Muted, "  env read: "))
987		sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", ")))
988		sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
989	}
990	return sb.String()
991}
992
993func bundledExtensionDirs(root string) []string {
994	entries, err := os.ReadDir(root)
995	if err != nil {
996		return nil
997	}
998	var out []string
999	for _, e := range entries {
1000		if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
1001			out = append(out, filepath.Join(root, e.Name()))
1002		}
1003	}
1004	return out
1005}
1006
1007func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
1008func dirExists(p string) bool  { st, err := os.Stat(p); return err == nil && st.IsDir() }
1009
1010func safeAgentName(s string) string {
1011	s = strings.TrimSpace(strings.ToLower(s))
1012	s = strings.ReplaceAll(s, "/", "_")
1013	s = strings.ReplaceAll(s, "\\", "_")
1014	s = strings.Map(func(r rune) rune {
1015		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
1016			return r
1017		}
1018		return '-'
1019	}, s)
1020	return strings.Trim(s, "-._")
1021}
1022
1023func digestDirectory(dir string) string {
1024	h := sha256.New()
1025	_ = writeCanonicalTar(dir, h)
1026	return hex.EncodeToString(h.Sum(nil))
1027}

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