v0.27.1 131edba

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

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