1package agent
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/url"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "runtime"
20 "sort"
21 "strings"
22 "time"
23
24 "github.com/bnema/zut/packages/agent/tools"
25 "github.com/bnema/zut/packages/provider"
26 "github.com/bnema/zut/packages/provider/auth"
27 "github.com/bnema/zut/packages/tui"
28 "github.com/klauspost/compress/zstd"
29 "golang.org/x/term"
30)
31
32type ZutfileManifest struct {
33 Zutfile int `json:"zutfile"`
34 Name string `json:"name"`
35 Version string `json:"version"`
36 Description string `json:"description"`
37 License string `json:"license"`
38 Runtime struct {
39 MinZut string `json:"min_zut"`
40 } `json:"runtime"`
41 Model struct {
42 Requires []string `json:"requires"`
43 MinContext int `json:"min_context"`
44 Preferred []string `json:"preferred"`
45 MinTier string `json:"min_tier"`
46 } `json:"model"`
47 Permissions tools.PermissionSet `json:"permissions"`
48 Requirements struct {
49 Bin []string `json:"bin"`
50 OS []string `json:"os"`
51 } `json:"requirements"`
52 Entry struct {
53 Greeting string `json:"greeting"`
54 Pre string `json:"pre"`
55 DefaultPrompt *string `json:"default_prompt"`
56 } `json:"entry"`
57 ReplaceSystemPrompt bool `json:"replace_system_prompt"`
58}
59
60type zutfileLoaded struct {
61 Dir string
62 Temp bool
63 Digest string
64 Manifest ZutfileManifest
65}
66
67func runZutfileCommand(rawArgs []string, version string) (bool, error) {
68 if len(rawArgs) == 0 {
69 return false, nil
70 }
71 switch rawArgs[0] {
72 case "pack":
73 dir := "."
74 out := ""
75 if len(rawArgs) > 1 {
76 dir = rawArgs[1]
77 }
78 if len(rawArgs) > 2 {
79 out = rawArgs[2]
80 }
81 return true, zutPack(dir, out)
82 case "inspect":
83 if len(rawArgs) < 2 {
84 return true, fmt.Errorf("zut inspect requires a name, .zut file, directory, or GitHub URL")
85 }
86 return true, zutInspect(rawArgs[1])
87 case "verify":
88 if len(rawArgs) < 2 {
89 return true, fmt.Errorf("zut verify requires a name, .zut file, directory, or GitHub URL")
90 }
91 zf, cleanup, err := loadZutfile(rawArgs[1])
92 if cleanup != nil {
93 defer cleanup()
94 }
95 if err != nil {
96 return true, err
97 }
98 fmt.Printf("ok digest sha256:%s\n", zf.Digest)
99 return true, nil
100 case "run":
101 if len(rawArgs) < 2 {
102 return true, fmt.Errorf("zut run requires a name, .zut file, directory, or GitHub URL")
103 }
104 yes, remaining := peelLeadingYes(rawArgs[1:])
105 if len(remaining) < 1 {
106 return true, fmt.Errorf("zut run requires a name, .zut file, directory, or GitHub URL")
107 }
108 ref := remaining[0]
109 rest := remaining[1:]
110 args, err := ParseArgs(rest)
111 if err != nil {
112 PrintHelp(version)
113 return true, err
114 }
115 if yes {
116 args.Yes = true
117 }
118 if args.Orchestrate {
119 return true, fmt.Errorf("--orchestrate is not supported for packaged agents")
120 }
121 if err := validateOrchestrationArgs(args); err != nil {
122 return true, err
123 }
124 return true, runLocalZutfile(ref, args, version)
125 default:
126 return false, nil
127 }
128}
129
130func runLocalZutfile(ref string, args Args, version string) error {
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 userModels := LoadUserModels()
161 catalogProvider, catalogModel := zutfileCatalogSelection(args, zf.Manifest, userModels)
162 PrepareRuntimeCatalog(context.Background(), true, catalogProvider, args.APIKey, args.BaseURL, userModels, catalogModel)
163 if err := applyZutfileModelRequirements(&args, zf.Manifest); err != nil {
164 return err
165 }
166 agentPath := filepath.Join(zf.Dir, "AGENT.md")
167 agentPrompt, err := os.ReadFile(agentPath)
168 switch {
169 case err == nil:
170 text := strings.TrimSpace(string(agentPrompt))
171 if zf.Manifest.ReplaceSystemPrompt {
172 args.SystemPrompt = text
173 } else {
174 args.AppendSystemPrompt = append(args.AppendSystemPrompt, text)
175 }
176 case os.IsNotExist(err):
177 // AGENT.md is optional; missing file leaves the system prompt unchanged.
178 default:
179 return fmt.Errorf("read AGENT.md: %w", err)
180 }
181 args.StartupPre = strings.TrimSpace(zf.Manifest.Entry.Pre)
182 if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
183 args.Prompt = *zf.Manifest.Entry.DefaultPrompt
184 }
185 if dirExists(filepath.Join(zf.Dir, "skills")) {
186 old := os.Getenv("ZUT_AGENT_SKILLS")
187 v := filepath.Join(zf.Dir, "skills")
188 if old != "" {
189 v += string(os.PathListSeparator) + old
190 }
191 _ = os.Setenv("ZUT_AGENT_SKILLS", v)
192 defer os.Setenv("ZUT_AGENT_SKILLS", old)
193 }
194 args.AgentName = zf.Manifest.Name
195 args.AgentDataDir = agentData
196 args.PermissionSet = &perms
197 return runWithArgs(args, version)
198}
199
200func zutInspect(ref string) error {
201 zf, cleanup, err := loadZutfile(ref)
202 if cleanup != nil {
203 defer cleanup()
204 }
205 if err != nil {
206 return err
207 }
208 m := zf.Manifest
209 fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
210 fmt.Println("\npermissions:")
211 fmt.Print(permissionSummary(m.Permissions))
212 fmt.Println("\nfiles:")
213 return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
214 if err != nil || path == zf.Dir {
215 return err
216 }
217 rel, _ := filepath.Rel(zf.Dir, path)
218 if d.IsDir() {
219 rel += "/"
220 }
221 fmt.Println(" " + filepath.ToSlash(rel))
222 return nil
223 })
224}
225
226// zutfileCatalogSelection derives the provider/model scope needed to refresh
227// dynamic catalogs before manifest preferences are applied. Unknown preferred
228// IDs remain useful as a model scope when the configured provider is dynamic.
229func zutfileCatalogSelection(args Args, m ZutfileManifest, userModels []provider.Model) (string, string) {
230 providerName := canonicalProvider(args.Provider)
231 modelID := strings.TrimSpace(args.Model)
232 if cfg, err := LoadConfig(); err == nil {
233 cfg.applyActiveModelProfile()
234 if providerName == "" {
235 providerName = canonicalProvider(cfg.Provider)
236 }
237 if modelID == "" {
238 modelID = strings.TrimSpace(cfg.Model)
239 }
240 }
241 if providerName == "" {
242 if _, ok := provider.CustomProviders()[provider.ProviderOpenCodeGo]; ok {
243 providerName = provider.ProviderOpenCodeGo
244 }
245 }
246 findProvider := func(id string) string {
247 for _, model := range userModels {
248 if strings.TrimSpace(model.ID) == id {
249 return canonicalProvider(model.Provider)
250 }
251 }
252 if model, err := provider.FindModel("", id); err == nil {
253 return canonicalProvider(model.Provider)
254 }
255 return ""
256 }
257 if providerName == "" && modelID != "" {
258 providerName = findProvider(modelID)
259 }
260 if modelID == "" {
261 for _, preferred := range m.Model.Preferred {
262 preferred = strings.TrimSpace(preferred)
263 if preferred == "" {
264 continue
265 }
266 modelID = preferred
267 if providerName == "" {
268 providerName = findProvider(preferred)
269 }
270 break
271 }
272 }
273 return providerName, modelID
274}
275
276func applyZutfileModelRequirements(args *Args, m ZutfileManifest) error {
277 minCtx := m.Model.MinContext
278 requires := map[string]bool{}
279 for _, capability := range m.Model.Requires {
280 requires[strings.ToLower(strings.TrimSpace(capability))] = true
281 }
282 for capability := range requires {
283 if capability != "tools" && capability != "vision" && capability != "reasoning" {
284 return fmt.Errorf("unsupported model requirement %q", capability)
285 }
286 }
287 if m.Model.MinTier != "" {
288 return fmt.Errorf("model.min_tier is not supported by this zut version")
289 }
290 compatible := func(model provider.Model) bool {
291 if model.ContextWindow < minCtx {
292 return false
293 }
294 if requires["reasoning"] && !model.Reasoning {
295 return false
296 }
297 // Every model exposed by the current catalog supports text and tools.
298 // Vision support is not represented in Model yet, so fail closed.
299 return !requires["vision"]
300 }
301 if minCtx <= 0 && len(requires) == 0 {
302 return nil
303 }
304 if args.Model != "" {
305 model, err := provider.FindModel(args.Provider, args.Model)
306 if err != nil {
307 model, err = provider.FindModel("", args.Model)
308 }
309 if err != nil {
310 return nil
311 }
312 if !compatible(model) {
313 return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
314 }
315 return nil
316 }
317 cfg, _ := LoadConfig()
318 if cfg.Model != "" {
319 model, err := provider.FindModel(cfg.Provider, cfg.Model)
320 if err == nil && compatible(model) {
321 return nil
322 }
323 }
324 for _, id := range m.Model.Preferred {
325 model, err := provider.FindModel("", id)
326 if err == nil && compatible(model) {
327 args.Provider = model.Provider
328 args.Model = model.ID
329 return nil
330 }
331 }
332 for _, model := range provider.Active() {
333 if compatible(model) {
334 args.Provider = model.Provider
335 args.Model = model.ID
336 return nil
337 }
338 }
339 return fmt.Errorf("no catalog model satisfies the agent requirements")
340}
341
342// PrepareRuntimeCatalog loads cached and user model metadata before a runtime
343// is resolved, then refreshes every eligible provider in the foreground or
344// background. The explicit endpoint is forwarded to account-scoped discovery
345// so credentials for a private OpenCode Go proxy never reach the production
346// endpoint. Callers pass the already-loaded user model slice so packaged-agent
347// startup does not parse models.json twice.
348func PrepareRuntimeCatalog(ctx context.Context, waitForRefresh bool, explicitProvider, explicitAPIKey, explicitBaseURL string, userModels []provider.Model, explicitModel ...string) {
349 if ctx == nil {
350 ctx = context.Background()
351 }
352 modelID := ""
353 if len(explicitModel) > 0 {
354 modelID = explicitModel[0]
355 }
356 catalogAPIKey := explicitAPIKey
357 catalogProvider := explicitProvider
358 if waitForRefresh && catalogAPIKey == "" {
359 catalogAPIKey = synchronousOpenCodeGoAPIKey(ctx, explicitProvider, explicitAPIKey)
360 if err := ctx.Err(); err != nil {
361 return
362 }
363 if catalogAPIKey != "" && effectiveCatalogProvider(explicitProvider) == "" {
364 catalogProvider = provider.ProviderOpenCodeGo
365 }
366 }
367 modelCatalogMu.Lock()
368 preparedProvider, preparedBaseURL := prepareRuntimeCatalog(catalogProvider, catalogAPIKey, explicitBaseURL, userModels, modelID)
369 modelCatalogMu.Unlock()
370 if waitForRefresh {
371 refreshModelsWithContext(ctx, preparedProvider, catalogAPIKey, preparedBaseURL, "", apiKeyCommandExecute)
372 } else {
373 RefreshModelsAsync(preparedProvider, explicitAPIKey, preparedBaseURL)
374 }
375}
376
377func effectiveCatalogProvider(explicitProvider string) string {
378 if providerName := canonicalProvider(explicitProvider); providerName != "" {
379 return providerName
380 }
381 cfg, err := LoadConfig()
382 if err != nil {
383 return ""
384 }
385 cfg.applyActiveModelProfile()
386 return canonicalProvider(cfg.Provider)
387}
388
389func effectiveCatalogBaseURL(explicitProvider, explicitModel, explicitBaseURL string, userModels []provider.Model) string {
390 baseURL := strings.TrimSpace(explicitBaseURL)
391 if baseURL != "" || explicitProvider != provider.ProviderOpenCodeGo {
392 return baseURL
393 }
394 modelID := strings.TrimSpace(explicitModel)
395 if modelID == "" {
396 if cfg, err := LoadConfig(); err == nil {
397 cfg.applyActiveModelProfile()
398 modelID = strings.TrimSpace(cfg.Model)
399 }
400 }
401 for _, model := range userModels {
402 if model.Provider != provider.ProviderOpenCodeGo {
403 continue
404 }
405 if modelID == "" || strings.TrimSpace(model.ID) == modelID {
406 return strings.TrimSpace(model.BaseURL)
407 }
408 }
409 if cfg, ok := provider.CustomProviders()[provider.ProviderOpenCodeGo]; ok {
410 return strings.TrimSpace(cfg.BaseURL)
411 }
412 return ""
413}
414
415func prepareRuntimeCatalog(explicitProvider, explicitAPIKey, explicitBaseURL string, userModels []provider.Model, explicitModel string) (string, string) {
416 explicitProvider = effectiveCatalogProvider(explicitProvider)
417 explicitBaseURL = effectiveCatalogBaseURL(explicitProvider, explicitModel, explicitBaseURL, userModels)
418 loadCachedModels(modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL))
419 if cps := provider.CustomProviders(); len(cps) > 0 {
420 var names []string
421 for name := range cps {
422 if !isBuiltinProvider(name) {
423 names = append(names, name)
424 }
425 }
426 auth.SetExtraAPIKeyProviders(names)
427 }
428 ValidateAndRepairConfig()
429 return explicitProvider, explicitBaseURL
430}
431
432func zutPack(dir, out string) error {
433 abs, err := filepath.Abs(dir)
434 if err != nil {
435 return err
436 }
437 if _, err := readZutManifest(abs); err != nil {
438 return err
439 }
440 if err := validateZutfileDir(abs); err != nil {
441 return err
442 }
443 if out == "" {
444 m, _ := readZutManifest(abs)
445 base := safeAgentName(m.Name)
446 if base == "" {
447 base = filepath.Base(abs)
448 }
449 out = base + ".zut"
450 }
451 if filepath.Ext(out) != ".zut" {
452 out += ".zut"
453 }
454 outAbs, err := filepath.Abs(out)
455 if err != nil {
456 return err
457 }
458 f, err := os.Create(out)
459 if err != nil {
460 return err
461 }
462 defer f.Close()
463 h := sha256.New()
464 mw := io.MultiWriter(f, h)
465 enc, err := zstd.NewWriter(mw)
466 if err != nil {
467 return err
468 }
469 if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
470 enc.Close()
471 return err
472 }
473 if err := enc.Close(); err != nil {
474 return err
475 }
476 fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
477 return nil
478}
479
480func loadZutfile(ref string) (zutfileLoaded, func(), error) {
481 resolved, err := resolveZutfileRef(ref)
482 if err != nil {
483 return zutfileLoaded{}, nil, err
484 }
485 ref = resolved
486 if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
487 return loadRemoteZutfile(u)
488 }
489 info, err := os.Stat(ref)
490 if err != nil {
491 return zutfileLoaded{}, nil, err
492 }
493 if info.IsDir() {
494 abs, _ := filepath.Abs(ref)
495 m, err := readZutManifest(abs)
496 if err != nil {
497 return zutfileLoaded{}, nil, err
498 }
499 if err := validateZutfileDir(abs); err != nil {
500 return zutfileLoaded{}, nil, err
501 }
502 return zutfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
503 }
504 tmp, err := os.MkdirTemp("", "zutfile-*")
505 if err != nil {
506 return zutfileLoaded{}, nil, err
507 }
508 cleanup := func() { _ = os.RemoveAll(tmp) }
509 digest, err := unpackZutArchive(ref, tmp)
510 if err != nil {
511 cleanup()
512 return zutfileLoaded{}, nil, err
513 }
514 m, err := readZutManifest(tmp)
515 if err != nil {
516 cleanup()
517 return zutfileLoaded{}, nil, err
518 }
519 if err := validateZutfileDir(tmp); err != nil {
520 cleanup()
521 return zutfileLoaded{}, nil, err
522 }
523 return zutfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
524}
525
526func resolveZutfileRef(ref string) (string, error) {
527 if u, err := url.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
528 return ref, nil
529 }
530 if _, err := os.Stat(ref); err == nil {
531 return ref, nil
532 } else if !os.IsNotExist(err) {
533 return "", err
534 }
535 if filepath.Ext(ref) == "" {
536 archive := ref + ".zut"
537 if _, err := os.Stat(archive); err == nil {
538 return archive, nil
539 } else if !os.IsNotExist(err) {
540 return "", err
541 }
542 }
543 if strings.HasSuffix(strings.ToLower(ref), ".zut") {
544 return ref, nil
545 }
546 githubRef := strings.ReplaceAll(ref, "\\", "/")
547 parts := strings.Split(githubRef, "/")
548 if len(parts) == 2 && validGitHubZutfileSegment(parts[0]) && validGitHubZutfileSegment(parts[1]) {
549 return "https://github.com/" + parts[0] + "/" + parts[1], nil
550 }
551 if len(parts) >= 3 {
552 for _, part := range parts {
553 if !validGitHubZutfileSegment(part) {
554 return ref, nil
555 }
556 }
557 return "https://github.com/" + strings.Join(parts, "/"), nil
558 }
559 return ref, nil
560}
561
562func validGitHubZutfileSegment(s string) bool {
563 return s != "" && s == strings.ToLower(s) && safeAgentName(s) == s
564}
565
566var zutfileHTTPClient = &http.Client{Timeout: 60 * time.Second}
567
568var githubArchiveURL = func(owner, repo, ref string) string {
569 return fmt.Sprintf("https://github.com/%s/%s/archive/%s.tar.gz", owner, repo, url.PathEscape(ref))
570}
571
572func loadRemoteZutfile(u *url.URL) (zutfileLoaded, func(), error) {
573 if !strings.EqualFold(u.Hostname(), "github.com") {
574 return zutfileLoaded{}, nil, fmt.Errorf("unsupported zutfile URL host %q; only github.com agent directories are supported", u.Hostname())
575 }
576 owner, repo, ref, subdir, err := parseGitHubAgentURL(u)
577 if err != nil {
578 return zutfileLoaded{}, nil, err
579 }
580 tmp, err := os.MkdirTemp("", "zutfile-github-*")
581 if err != nil {
582 return zutfileLoaded{}, nil, err
583 }
584 cleanup := func() { _ = os.RemoveAll(tmp) }
585 if err := downloadGitHubArchive(githubArchiveURL(owner, repo, ref), tmp); err != nil {
586 cleanup()
587 return zutfileLoaded{}, nil, err
588 }
589 root, err := singleExtractedRoot(tmp)
590 if err != nil {
591 cleanup()
592 return zutfileLoaded{}, nil, err
593 }
594 dir := filepath.Join(root, filepath.FromSlash(subdir))
595 if subdir == "" {
596 dir = root
597 }
598 if !pathWithin(root, dir) {
599 cleanup()
600 return zutfileLoaded{}, nil, fmt.Errorf("unsafe GitHub agent path %q", subdir)
601 }
602 m, err := readZutManifest(dir)
603 if err != nil {
604 cleanup()
605 return zutfileLoaded{}, nil, fmt.Errorf("GitHub agent %s/%s/%s: %w", owner, repo, subdir, err)
606 }
607 if err := validateZutfileDir(dir); err != nil {
608 cleanup()
609 return zutfileLoaded{}, nil, err
610 }
611 return zutfileLoaded{Dir: dir, Temp: true, Digest: digestDirectory(dir), Manifest: m}, cleanup, nil
612}
613
614func parseGitHubAgentURL(u *url.URL) (owner, repo, ref, subdir string, err error) {
615 parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/")
616 for i := range parts {
617 parts[i], err = url.PathUnescape(parts[i])
618 if err != nil {
619 return "", "", "", "", fmt.Errorf("invalid GitHub URL path: %w", err)
620 }
621 }
622 if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
623 return "", "", "", "", fmt.Errorf("GitHub agent URL must include an owner and repository")
624 }
625 owner, repo, ref = parts[0], strings.TrimSuffix(parts[1], ".git"), "HEAD"
626 if len(parts) >= 4 && parts[2] == "tree" {
627 ref = parts[3]
628 subdir = strings.Join(parts[4:], "/")
629 } else {
630 subdir = strings.Join(parts[2:], "/")
631 }
632 if repo == "" || ref == "" {
633 return "", "", "", "", fmt.Errorf("invalid GitHub agent URL")
634 }
635 clean := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(filepath.FromSlash(subdir))), "./")
636 if clean == "." {
637 clean = ""
638 }
639 if clean == ".." || strings.HasPrefix(clean, "../") {
640 return "", "", "", "", fmt.Errorf("unsafe GitHub agent path %q", subdir)
641 }
642 return owner, repo, ref, clean, nil
643}
644
645func downloadGitHubArchive(archiveURL, dst string) error {
646 req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
647 if err != nil {
648 return err
649 }
650 req.Header.Set("User-Agent", "zut")
651 resp, err := zutfileHTTPClient.Do(req)
652 if err != nil {
653 return fmt.Errorf("download GitHub repository: %w", err)
654 }
655 defer resp.Body.Close()
656 if resp.StatusCode != http.StatusOK {
657 return fmt.Errorf("download GitHub repository: HTTP %d", resp.StatusCode)
658 }
659 limited := &io.LimitedReader{R: resp.Body, N: maxZutfileCompressedSize + 1}
660 gr, err := gzip.NewReader(limited)
661 if err != nil {
662 return fmt.Errorf("read GitHub archive: %w", err)
663 }
664 defer gr.Close()
665 if err := untar(gr, dst); err != nil {
666 return fmt.Errorf("extract GitHub archive: %w", err)
667 }
668 if limited.N <= 0 {
669 return fmt.Errorf("GitHub archive exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
670 }
671 return nil
672}
673
674func pathWithin(root, target string) bool {
675 rel, err := filepath.Rel(root, target)
676 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
677}
678
679func singleExtractedRoot(dir string) (string, error) {
680 entries, err := os.ReadDir(dir)
681 if err != nil {
682 return "", err
683 }
684 if len(entries) != 1 || !entries[0].IsDir() {
685 return "", fmt.Errorf("GitHub archive has an unexpected layout")
686 }
687 return filepath.Join(dir, entries[0].Name()), nil
688}
689
690func readZutManifest(dir string) (ZutfileManifest, error) {
691 var m ZutfileManifest
692 b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
693 if err != nil {
694 return m, fmt.Errorf("manifest.json is required: %w", err)
695 }
696 if err := json.Unmarshal(b, &m); err != nil {
697 return m, fmt.Errorf("manifest.json: %w", err)
698 }
699 if m.Zutfile != 1 {
700 return m, fmt.Errorf("unsupported zutfile version %d", m.Zutfile)
701 }
702 name := strings.TrimSpace(m.Name)
703 if name == "" {
704 return m, fmt.Errorf("manifest name is required")
705 }
706 if name != strings.ToLower(name) || safeAgentName(name) != name {
707 return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
708 }
709 if len(m.Permissions.Net.Allow) > 0 {
710 return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
711 }
712 if len(m.Permissions.Env.Read) > 0 {
713 return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
714 }
715 mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
716 if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
717 return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
718 }
719 if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
720 return m, fmt.Errorf("bash allowlist mode requires at least one command")
721 }
722 return m, nil
723}
724
725func validateZutfileDir(dir string) error {
726 agentPath := filepath.Join(dir, "AGENT.md")
727 st, err := os.Stat(agentPath)
728 switch {
729 case err == nil:
730 if !st.Mode().IsRegular() {
731 return fmt.Errorf("AGENT.md must be a regular file")
732 }
733 case os.IsNotExist(err):
734 // AGENT.md is optional.
735 default:
736 return fmt.Errorf("stat AGENT.md: %w", err)
737 }
738 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
739 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
740 }
741 return nil
742}
743
744const (
745 maxZutfileCompressedSize = 100 << 20
746 maxZutfileEntrySize = 64 << 20
747 maxZutfileExpandedSize = 256 << 20
748)
749
750func unpackZutArchive(path, dst string) (string, error) {
751 info, err := os.Stat(path)
752 if err != nil {
753 return "", err
754 }
755 if info.Size() > maxZutfileCompressedSize {
756 return "", fmt.Errorf("zutfile exceeds %d MiB compressed size limit", maxZutfileCompressedSize>>20)
757 }
758 b, err := os.ReadFile(path)
759 if err != nil {
760 return "", err
761 }
762 digest := sha256.Sum256(b)
763 r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZutfileExpandedSize))
764 if err != nil {
765 // Development fallback for older experiments.
766 gr, gerr := gzip.NewReader(bytes.NewReader(b))
767 if gerr != nil {
768 return "", err
769 }
770 defer gr.Close()
771 return hex.EncodeToString(digest[:]), untar(gr, dst)
772 }
773 defer r.Close()
774 return hex.EncodeToString(digest[:]), untar(r, dst)
775}
776
777func untar(r io.Reader, dst string) error {
778 tr := tar.NewReader(r)
779 var expanded int64
780 for {
781 h, err := tr.Next()
782 if err == io.EOF {
783 return nil
784 }
785 if err != nil {
786 return err
787 }
788 name := filepath.Clean(h.Name)
789 if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
790 return fmt.Errorf("unsafe path in archive: %s", h.Name)
791 }
792 path := filepath.Join(dst, name)
793 if h.Size < 0 || h.Size > maxZutfileEntrySize || expanded+h.Size > maxZutfileExpandedSize {
794 return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
795 }
796 expanded += h.Size
797 switch h.Typeflag {
798 case tar.TypeDir:
799 if err := os.MkdirAll(path, 0o755); err != nil {
800 return err
801 }
802 case tar.TypeReg:
803 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
804 return err
805 }
806 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
807 if err != nil {
808 return err
809 }
810 _, cerr := io.Copy(f, tr)
811 if err := f.Close(); err != nil && cerr == nil {
812 cerr = err
813 }
814 if cerr != nil {
815 return cerr
816 }
817 }
818 }
819}
820
821func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
822 excluded := map[string]bool{}
823 for _, p := range exclude {
824 if p == "" {
825 continue
826 }
827 abs, err := filepath.Abs(p)
828 if err == nil {
829 excluded[abs] = true
830 }
831 }
832 var files []string
833 if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
834 if err != nil || path == root {
835 return err
836 }
837 absPath, _ := filepath.Abs(path)
838 if excluded[absPath] {
839 return nil
840 }
841 if d.Type()&os.ModeSymlink != 0 {
842 return fmt.Errorf("symlinks are not supported in zutfiles: %s", path)
843 }
844 files = append(files, path)
845 return nil
846 }); err != nil {
847 return err
848 }
849 sort.Strings(files)
850 tw := tar.NewWriter(w)
851 defer tw.Close()
852 fixed := time.Unix(0, 0)
853 for _, path := range files {
854 info, err := os.Stat(path)
855 if err != nil {
856 return err
857 }
858 rel, _ := filepath.Rel(root, path)
859 rel = filepath.ToSlash(rel)
860 h, err := tar.FileInfoHeader(info, "")
861 if err != nil {
862 return err
863 }
864 h.Name = rel
865 h.ModTime = fixed
866 h.AccessTime = fixed
867 h.ChangeTime = fixed
868 h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
869 if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
870 h.Name += "/"
871 }
872 if err := tw.WriteHeader(h); err != nil {
873 return err
874 }
875 if info.Mode().IsRegular() {
876 f, err := os.Open(path)
877 if err != nil {
878 return err
879 }
880 _, cerr := io.Copy(tw, f)
881 _ = f.Close()
882 if cerr != nil {
883 return cerr
884 }
885 }
886 }
887 return nil
888}
889
890func checkZutfileRequirements(zf zutfileLoaded, version string) error {
891 if min := strings.TrimSpace(zf.Manifest.Runtime.MinZut); min != "" {
892 if isDevVersion(version) {
893 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
894 }
895 comparison, err := compareVersions(version, min)
896 if err != nil {
897 return fmt.Errorf("validate min_zut requirement: %w", err)
898 }
899 if comparison < 0 {
900 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
901 }
902 }
903 if len(zf.Manifest.Requirements.OS) > 0 {
904 ok := false
905 for _, osName := range zf.Manifest.Requirements.OS {
906 if osName == runtime.GOOS {
907 ok = true
908 break
909 }
910 }
911 if !ok {
912 return fmt.Errorf("agent does not support %s", runtime.GOOS)
913 }
914 }
915 if len(zf.Manifest.Requirements.Bin) > 0 {
916 for _, b := range zf.Manifest.Requirements.Bin {
917 if _, err := execLookPath(b); err != nil {
918 return fmt.Errorf("agent requires binary %q", b)
919 }
920 }
921 }
922 return nil
923}
924
925var execLookPath = exec.LookPath
926
927func peelLeadingYes(args []string) (yes bool, rest []string) {
928 rest = args
929 for len(rest) > 0 {
930 switch rest[0] {
931 case "-y", "--yes":
932 yes = true
933 rest = rest[1:]
934 default:
935 return yes, rest
936 }
937 }
938 return yes, rest
939}
940
941func consentZutfile(zf zutfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
942 if autoYes || os.Getenv("ZUT_AGENT_CONSENT") == "1" {
943 return acceptZutfileConsent(zf, perms)
944 }
945 // "ask" deliberately requires approval on every launch. Other consent
946 // is durable only for this exact artifact digest, so any package change
947 // causes a fresh prompt.
948 consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
949 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
950 if _, err := os.Stat(consentPath); err == nil {
951 return true, nil
952 }
953 }
954 interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
955 th := tui.Dark
956 if interactive {
957 cfg, _ := LoadConfig()
958 th, _, _ = tui.DetectThemeWithCustom(ZutHome(), cfg.Theme, 80*time.Millisecond)
959 }
960 fmt.Print(formatZutfileConsent(zf, perms, th, interactive))
961 if !interactive {
962 return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZUT_AGENT_CONSENT=1 to allow")
963 }
964 allowed, err := readZutfileConsent(os.Stdin, os.Stdout, th)
965 if err != nil || !allowed {
966 return allowed, err
967 }
968 return acceptZutfileConsent(zf, perms)
969}
970
971// acceptZutfileConsent records durable consent when the bash mode is not
972// "ask". Ask mode never caches receipts so every launch re-prompts unless
973// -y / ZUT_AGENT_CONSENT is used for that run.
974func acceptZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet) (bool, error) {
975 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
976 return true, nil
977 }
978 consentPath := filepath.Join(ZutHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
979 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
980 return false, fmt.Errorf("save agent consent: %w", err)
981 }
982 receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
983 data, _ := json.MarshalIndent(receipt, "", " ")
984 if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
985 return false, fmt.Errorf("save agent consent: %w", err)
986 }
987 return true, nil
988}
989
990var errZutfileConsentInterrupted = errors.New("interrupted")
991
992func formatZutfileConsent(zf zutfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string {
993 style := func(c tui.TerminalColor, text string) string {
994 if !color {
995 return text
996 }
997 return th.FGColor(c, text)
998 }
999
1000 var sb strings.Builder
1001 sb.WriteString(style(th.Assistant, "Agent"))
1002 sb.WriteByte(' ')
1003 sb.WriteString(style(th.Accent, zf.Manifest.Name+"@"+zf.Manifest.Version))
1004 sb.WriteString(style(th.FG, " wants to run."))
1005 sb.WriteString("\n\n")
1006 sb.WriteString(permissionSummaryStyled(perms, th, color))
1007 if color {
1008 sb.WriteByte('\n')
1009 sb.WriteString(style(th.Assistant, "Allow?"))
1010 sb.WriteByte(' ')
1011 sb.WriteString(style(th.Muted, "[y/n]"))
1012 sb.WriteByte(' ')
1013 }
1014 return sb.String()
1015}
1016
1017// readZutfileConsent accepts one y/n answer followed by Enter without echoing
1018// unrelated input. Raw mode also turns Ctrl+C into a byte so the terminal can
1019// be restored before the command aborts.
1020func readZutfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) {
1021 fd := int(in.Fd())
1022 state, err := term.MakeRaw(fd)
1023 if err != nil {
1024 return false, fmt.Errorf("read agent consent: %w", err)
1025 }
1026 defer func() { _ = term.Restore(fd, state) }()
1027
1028 answer, err := readZutfileConsentKey(in, func(answer byte) {
1029 if answer == 0 {
1030 fmt.Fprint(out, "\b \b")
1031 return
1032 }
1033 fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
1034 })
1035 switch {
1036 case errors.Is(err, errZutfileConsentInterrupted):
1037 fmt.Fprint(out, "^C\r\n")
1038 return false, err
1039 case err != nil:
1040 fmt.Fprint(out, "\r\n")
1041 return false, fmt.Errorf("read agent consent: %w", err)
1042 default:
1043 fmt.Fprint(out, "\r\n")
1044 return answer == 'y', nil
1045 }
1046}
1047
1048func readZutfileConsentKey(r io.Reader, echo func(byte)) (byte, error) {
1049 var answer byte
1050 var b [1]byte
1051 for {
1052 if _, err := io.ReadFull(r, b[:]); err != nil {
1053 return 0, err
1054 }
1055 switch b[0] {
1056 case 'y', 'Y', 'n', 'N':
1057 if answer == 0 {
1058 answer = b[0] | 0x20
1059 if echo != nil {
1060 echo(answer)
1061 }
1062 }
1063 case '\r', '\n':
1064 if answer != 0 {
1065 return answer, nil
1066 }
1067 case '\b', 0x7f:
1068 if answer != 0 {
1069 answer = 0
1070 if echo != nil {
1071 echo(0)
1072 }
1073 }
1074 case 3: // Ctrl+C in raw mode.
1075 return 0, errZutfileConsentInterrupted
1076 }
1077 }
1078}
1079
1080func permissionSummary(p tools.PermissionSet) string {
1081 return permissionSummaryStyled(p, tui.Theme{}, false)
1082}
1083
1084func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string {
1085 style := func(c tui.TerminalColor, text string) string {
1086 if !color {
1087 return text
1088 }
1089 return th.FGColor(c, text)
1090 }
1091 permissionValue := func(value string) string { return style(th.FG, value) }
1092
1093 var sb strings.Builder
1094 sb.WriteString(style(th.Muted, " fs read: "))
1095 if len(p.FS.Read) > 0 {
1096 sb.WriteString(permissionValue(strings.Join(p.FS.Read, ", ")))
1097 } else {
1098 sb.WriteString(style(th.Muted, "none"))
1099 }
1100 sb.WriteByte('\n')
1101 sb.WriteString(style(th.Muted, " fs write: "))
1102 if len(p.FS.Write) > 0 {
1103 sb.WriteString(permissionValue(strings.Join(p.FS.Write, ", ")))
1104 } else {
1105 sb.WriteString(style(th.Muted, "none"))
1106 }
1107 sb.WriteByte('\n')
1108 mode := p.Bash.Mode
1109 if mode == "" {
1110 mode = "none"
1111 }
1112 sb.WriteString(style(th.Muted, " bash: "))
1113 modeColor := th.FG
1114 if mode == "ask" {
1115 modeColor = th.Warning
1116 }
1117 sb.WriteString(style(modeColor, mode))
1118 if len(p.Bash.Allow) > 0 {
1119 sb.WriteString(style(th.Muted, " ("))
1120 sb.WriteString(permissionValue(strings.Join(p.Bash.Allow, ", ")))
1121 sb.WriteString(style(th.Muted, ")"))
1122 }
1123 sb.WriteByte('\n')
1124 if len(p.Net.Allow) > 0 {
1125 sb.WriteString(style(th.Muted, " net: "))
1126 sb.WriteString(permissionValue(strings.Join(p.Net.Allow, ", ")))
1127 sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
1128 }
1129 if len(p.Env.Read) > 0 {
1130 sb.WriteString(style(th.Muted, " env read: "))
1131 sb.WriteString(permissionValue(strings.Join(p.Env.Read, ", ")))
1132 sb.WriteString(style(th.Warning, " (declared, not enforced in this build)\n"))
1133 }
1134 return sb.String()
1135}
1136
1137func bundledExtensionDirs(root string) []string {
1138 entries, err := os.ReadDir(root)
1139 if err != nil {
1140 return nil
1141 }
1142 var out []string
1143 for _, e := range entries {
1144 if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
1145 out = append(out, filepath.Join(root, e.Name()))
1146 }
1147 }
1148 return out
1149}
1150
1151func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
1152func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
1153
1154func safeAgentName(s string) string {
1155 s = strings.TrimSpace(strings.ToLower(s))
1156 s = strings.ReplaceAll(s, "/", "_")
1157 s = strings.ReplaceAll(s, "\\", "_")
1158 s = strings.Map(func(r rune) rune {
1159 if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
1160 return r
1161 }
1162 return '-'
1163 }, s)
1164 return strings.Trim(s, "-._")
1165}
1166
1167func digestDirectory(dir string) string {
1168 h := sha256.New()
1169 _ = writeCanonicalTar(dir, h)
1170 return hex.EncodeToString(h.Sum(nil))
1171}