detached 0b19f96

Harden local Zotfile runtime

commit
0b19f961c3ef350c75d715d430e8d8debd38861d
author
patriceckhart · · 2026-07-12 11:52 UTC
parent
7b0ddf3
signature
unsigned
5 files · +310 -19
M go.mod +1 -1
@@ -5,6 +5,7 @@ go 1.25.0
55 require (
66 github.com/alecthomas/chroma/v2 v2.23.1
77 github.com/google/uuid v1.6.0
8+ github.com/klauspost/compress v1.19.0
89 github.com/mattn/go-runewidth v0.0.16
910 github.com/sahilm/fuzzy v0.1.1
1011 golang.org/x/image v0.43.0
@@ -14,7 +15,6 @@ require (
1415
1516 require (
1617 github.com/dlclark/regexp2 v1.11.5 // indirect
17- github.com/klauspost/compress v1.19.0 // indirect
1818 github.com/kylelemons/godebug v1.1.0 // indirect
1919 github.com/rivo/uniseg v0.2.0 // indirect
2020 )
@@ -5,6 +5,7 @@ go 1.25.0
5 require (5 require (
6 github.com/alecthomas/chroma/v2 v2.23.16 github.com/alecthomas/chroma/v2 v2.23.1
7 github.com/google/uuid v1.6.07 github.com/google/uuid v1.6.0
8+ github.com/klauspost/compress v1.19.0
8 github.com/mattn/go-runewidth v0.0.169 github.com/mattn/go-runewidth v0.0.16
9 github.com/sahilm/fuzzy v0.1.110 github.com/sahilm/fuzzy v0.1.1
10 golang.org/x/image v0.43.011 golang.org/x/image v0.43.0
@@ -14,7 +15,6 @@ require (
14 15
15 require (16 require (
16 github.com/dlclark/regexp2 v1.11.5 // indirect17 github.com/dlclark/regexp2 v1.11.5 // indirect
17- github.com/klauspost/compress v1.19.0 // indirect
18 github.com/kylelemons/godebug v1.1.0 // indirect18 github.com/kylelemons/godebug v1.1.0 // indirect
19 github.com/rivo/uniseg v0.2.0 // indirect19 github.com/rivo/uniseg v0.2.0 // indirect
20 )20 )
M packages/agent/tools/permissions.go +19 -3
@@ -34,6 +34,11 @@ func (p PermissionSet) Expand(workspace, agentData string) PermissionSet {
3434 }
3535 return s
3636 }
37+ // Clone the slices before expanding them. PermissionSet is copied by value,
38+ // but slices otherwise still alias the manifest and repeated expansion (for
39+ // example after /cd) would mutate the original declaration.
40+ p.FS.Read = append([]string(nil), p.FS.Read...)
41+ p.FS.Write = append([]string(nil), p.FS.Write...)
3742 for i, v := range p.FS.Read {
3843 p.FS.Read[i] = expand(v)
3944 }
@@ -54,9 +59,12 @@ func (s *Sandbox) CheckReadPath(path string) error {
5459 if err := s.CheckPath(path); err != nil {
5560 return err
5661 }
57- if s == nil || s.Permissions == nil || len(s.Permissions.FS.Read) == 0 {
62+ if s == nil || s.Permissions == nil {
5863 return nil
5964 }
65+ if len(s.Permissions.FS.Read) == 0 {
66+ return fmt.Errorf("permission denied: this agent has no filesystem read permission")
67+ }
6068 return checkScopedPath("read", path, s.Permissions.FS.Read)
6169 }
6270
@@ -64,9 +72,12 @@ func (s *Sandbox) CheckWritePath(path string) error {
6472 if err := s.CheckPath(path); err != nil {
6573 return err
6674 }
67- if s == nil || s.Permissions == nil || len(s.Permissions.FS.Write) == 0 {
75+ if s == nil || s.Permissions == nil {
6876 return nil
6977 }
78+ if len(s.Permissions.FS.Write) == 0 {
79+ return fmt.Errorf("permission denied: this agent has no filesystem write permission")
80+ }
7081 return checkScopedPath("write", path, s.Permissions.FS.Write)
7182 }
7283
@@ -99,15 +110,20 @@ func (s *Sandbox) CheckBashPermission(cmd string) error {
99110 case "none":
100111 return fmt.Errorf("permission denied: this agent has no bash permission")
101112 case "ask":
113+ // The local runtime obtains explicit consent for this capability at
114+ // every launch. Unlike durable consent, ask mode is never cached.
102115 return nil
103116 case "allowlist":
117+ if strings.ContainsAny(cmd, "`\n\r<>") || strings.Contains(cmd, "$(") {
118+ return fmt.Errorf("permission denied: bash allowlist does not permit substitution or redirection")
119+ }
104120 names := commandNames(cmd)
105121 if len(names) == 0 {
106122 return fmt.Errorf("permission denied: empty command")
107123 }
108124 allowed := map[string]bool{}
109125 for _, name := range s.Permissions.Bash.Allow {
110- allowed[name] = true
126+ allowed[filepath.Base(strings.TrimSpace(name))] = true
111127 }
112128 for _, name := range names {
113129 if !allowed[name] {
@@ -34,6 +34,11 @@ func (p PermissionSet) Expand(workspace, agentData string) PermissionSet {
34 }34 }
35 return s35 return s
36 }36 }
37+ // Clone the slices before expanding them. PermissionSet is copied by value,
38+ // but slices otherwise still alias the manifest and repeated expansion (for
39+ // example after /cd) would mutate the original declaration.
40+ p.FS.Read = append([]string(nil), p.FS.Read...)
41+ p.FS.Write = append([]string(nil), p.FS.Write...)
37 for i, v := range p.FS.Read {42 for i, v := range p.FS.Read {
38 p.FS.Read[i] = expand(v)43 p.FS.Read[i] = expand(v)
39 }44 }
@@ -54,9 +59,12 @@ func (s *Sandbox) CheckReadPath(path string) error {
54 if err := s.CheckPath(path); err != nil {59 if err := s.CheckPath(path); err != nil {
55 return err60 return err
56 }61 }
57- if s == nil || s.Permissions == nil || len(s.Permissions.FS.Read) == 0 {62+ if s == nil || s.Permissions == nil {
58 return nil63 return nil
59 }64 }
65+ if len(s.Permissions.FS.Read) == 0 {
66+ return fmt.Errorf("permission denied: this agent has no filesystem read permission")
67+ }
60 return checkScopedPath("read", path, s.Permissions.FS.Read)68 return checkScopedPath("read", path, s.Permissions.FS.Read)
61 }69 }
62 70
@@ -64,9 +72,12 @@ func (s *Sandbox) CheckWritePath(path string) error {
64 if err := s.CheckPath(path); err != nil {72 if err := s.CheckPath(path); err != nil {
65 return err73 return err
66 }74 }
67- if s == nil || s.Permissions == nil || len(s.Permissions.FS.Write) == 0 {75+ if s == nil || s.Permissions == nil {
68 return nil76 return nil
69 }77 }
78+ if len(s.Permissions.FS.Write) == 0 {
79+ return fmt.Errorf("permission denied: this agent has no filesystem write permission")
80+ }
70 return checkScopedPath("write", path, s.Permissions.FS.Write)81 return checkScopedPath("write", path, s.Permissions.FS.Write)
71 }82 }
72 83
@@ -99,15 +110,20 @@ func (s *Sandbox) CheckBashPermission(cmd string) error {
99 case "none":110 case "none":
100 return fmt.Errorf("permission denied: this agent has no bash permission")111 return fmt.Errorf("permission denied: this agent has no bash permission")
101 case "ask":112 case "ask":
113+ // The local runtime obtains explicit consent for this capability at
114+ // every launch. Unlike durable consent, ask mode is never cached.
102 return nil115 return nil
103 case "allowlist":116 case "allowlist":
117+ if strings.ContainsAny(cmd, "`\n\r<>") || strings.Contains(cmd, "$(") {
118+ return fmt.Errorf("permission denied: bash allowlist does not permit substitution or redirection")
119+ }
104 names := commandNames(cmd)120 names := commandNames(cmd)
105 if len(names) == 0 {121 if len(names) == 0 {
106 return fmt.Errorf("permission denied: empty command")122 return fmt.Errorf("permission denied: empty command")
107 }123 }
108 allowed := map[string]bool{}124 allowed := map[string]bool{}
109 for _, name := range s.Permissions.Bash.Allow {125 for _, name := range s.Permissions.Bash.Allow {
110- allowed[name] = true126+ allowed[filepath.Base(strings.TrimSpace(name))] = true
111 }127 }
112 for _, name := range names {128 for _, name := range names {
113 if !allowed[name] {129 if !allowed[name] {
A packages/agent/tools/permissions_test.go +65 -0
@@ -0,0 +1,65 @@
1+package tools
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) {
10+ s := NewSandbox(t.TempDir())
11+ s.SetPermissions(&PermissionSet{})
12+ if err := s.CheckReadPath(filepath.Join(s.Root, "a")); err == nil {
13+ t.Fatal("empty read scope allowed access")
14+ }
15+ if err := s.CheckWritePath(filepath.Join(s.Root, "a")); err == nil {
16+ t.Fatal("empty write scope allowed access")
17+ }
18+}
19+
20+func TestPermissionSetScopedAccess(t *testing.T) {
21+ root := t.TempDir()
22+ var p PermissionSet
23+ p.FS.Read = []string{"${workspace}/src"}
24+ p.FS.Write = []string{"${agent_data}"}
25+ expanded := p.Expand(root, filepath.Join(root, "data"))
26+ s := NewSandbox(root)
27+ s.SetPermissions(&expanded)
28+ if err := s.CheckReadPath(filepath.Join(root, "src", "a.go")); err != nil {
29+ t.Fatalf("read inside scope: %v", err)
30+ }
31+ if err := s.CheckReadPath(filepath.Join(root, "secret")); err == nil {
32+ t.Fatal("read outside scope was allowed")
33+ }
34+ if err := s.CheckWritePath(filepath.Join(root, "data", "state.json")); err != nil {
35+ t.Fatalf("write inside scope: %v", err)
36+ }
37+}
38+
39+func TestPermissionExpandDoesNotMutateManifest(t *testing.T) {
40+ var p PermissionSet
41+ p.FS.Read = []string{"${workspace}"}
42+ _ = p.Expand("/first", "/data")
43+ if got := p.FS.Read[0]; got != "${workspace}" {
44+ t.Fatalf("manifest mutated to %q", got)
45+ }
46+}
47+
48+func TestBashAllowlistRejectsShellEscapes(t *testing.T) {
49+ var p PermissionSet
50+ p.Bash.Mode = "allowlist"
51+ p.Bash.Allow = []string{"echo"}
52+ s := NewSandbox(t.TempDir())
53+ s.SetPermissions(&p)
54+ for _, cmd := range []string{"echo $(uname)", "echo hi > /tmp/out", "echo `uname`", "echo ok\nrm -rf /"} {
55+ if err := s.CheckBashPermission(cmd); err == nil || !strings.Contains(err.Error(), "does not permit") {
56+ t.Errorf("command %q was not rejected: %v", cmd, err)
57+ }
58+ }
59+ if err := s.CheckBashPermission("echo ok"); err != nil {
60+ t.Fatalf("allowed command rejected: %v", err)
61+ }
62+ if err := s.CheckBashPermission("rm file"); err == nil {
63+ t.Fatal("unlisted command was allowed")
64+ }
65+}
@@ -0,0 +1,65 @@
1+package tools
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPermissionSetEmptyScopesDenyAccess(t *testing.T) {
10+ s := NewSandbox(t.TempDir())
11+ s.SetPermissions(&PermissionSet{})
12+ if err := s.CheckReadPath(filepath.Join(s.Root, "a")); err == nil {
13+ t.Fatal("empty read scope allowed access")
14+ }
15+ if err := s.CheckWritePath(filepath.Join(s.Root, "a")); err == nil {
16+ t.Fatal("empty write scope allowed access")
17+ }
18+}
19+
20+func TestPermissionSetScopedAccess(t *testing.T) {
21+ root := t.TempDir()
22+ var p PermissionSet
23+ p.FS.Read = []string{"${workspace}/src"}
24+ p.FS.Write = []string{"${agent_data}"}
25+ expanded := p.Expand(root, filepath.Join(root, "data"))
26+ s := NewSandbox(root)
27+ s.SetPermissions(&expanded)
28+ if err := s.CheckReadPath(filepath.Join(root, "src", "a.go")); err != nil {
29+ t.Fatalf("read inside scope: %v", err)
30+ }
31+ if err := s.CheckReadPath(filepath.Join(root, "secret")); err == nil {
32+ t.Fatal("read outside scope was allowed")
33+ }
34+ if err := s.CheckWritePath(filepath.Join(root, "data", "state.json")); err != nil {
35+ t.Fatalf("write inside scope: %v", err)
36+ }
37+}
38+
39+func TestPermissionExpandDoesNotMutateManifest(t *testing.T) {
40+ var p PermissionSet
41+ p.FS.Read = []string{"${workspace}"}
42+ _ = p.Expand("/first", "/data")
43+ if got := p.FS.Read[0]; got != "${workspace}" {
44+ t.Fatalf("manifest mutated to %q", got)
45+ }
46+}
47+
48+func TestBashAllowlistRejectsShellEscapes(t *testing.T) {
49+ var p PermissionSet
50+ p.Bash.Mode = "allowlist"
51+ p.Bash.Allow = []string{"echo"}
52+ s := NewSandbox(t.TempDir())
53+ s.SetPermissions(&p)
54+ for _, cmd := range []string{"echo $(uname)", "echo hi > /tmp/out", "echo `uname`", "echo ok\nrm -rf /"} {
55+ if err := s.CheckBashPermission(cmd); err == nil || !strings.Contains(err.Error(), "does not permit") {
56+ t.Errorf("command %q was not rejected: %v", cmd, err)
57+ }
58+ }
59+ if err := s.CheckBashPermission("echo ok"); err != nil {
60+ t.Fatalf("allowed command rejected: %v", err)
61+ }
62+ if err := s.CheckBashPermission("rm file"); err == nil {
63+ t.Fatal("unlisted command was allowed")
64+ }
65+}
M packages/agent/zotfile.go +117 -15
@@ -117,7 +117,7 @@ func runLocalZotfile(ref string, args Args, version string) error {
117117 if err != nil {
118118 return err
119119 }
120- if err := checkZotfileRequirements(zf); err != nil {
120+ if err := checkZotfileRequirements(zf, version); err != nil {
121121 return err
122122 }
123123 agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
@@ -144,9 +144,6 @@ func runLocalZotfile(ref string, args Args, version string) error {
144144 if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
145145 args.Prompt = *zf.Manifest.Entry.DefaultPrompt
146146 }
147- if exts := bundledExtensionDirs(filepath.Join(zf.Dir, "extensions")); len(exts) > 0 {
148- args.Exts = append(args.Exts, exts...)
149- }
150147 if dirExists(filepath.Join(zf.Dir, "skills")) {
151148 old := os.Getenv("ZOT_AGENT_SKILLS")
152149 v := filepath.Join(zf.Dir, "skills")
@@ -190,11 +187,31 @@ func zotInspect(ref string) error {
190187
191188 func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
192189 minCtx := m.Model.MinContext
193- if minCtx <= 0 {
194- return nil
190+ requires := map[string]bool{}
191+ for _, capability := range m.Model.Requires {
192+ requires[strings.ToLower(strings.TrimSpace(capability))] = true
193+ }
194+ for capability := range requires {
195+ if capability != "tools" && capability != "vision" && capability != "reasoning" {
196+ return fmt.Errorf("unsupported model requirement %q", capability)
197+ }
198+ }
199+ if m.Model.MinTier != "" {
200+ return fmt.Errorf("model.min_tier is not supported by this zot version")
195201 }
196202 compatible := func(model provider.Model) bool {
197- return model.ContextWindow >= minCtx
203+ if model.ContextWindow < minCtx {
204+ return false
205+ }
206+ if requires["reasoning"] && !model.Reasoning {
207+ return false
208+ }
209+ // Every model exposed by the current catalog supports text and tools.
210+ // Vision support is not represented in Model yet, so fail closed.
211+ return !requires["vision"]
212+ }
213+ if minCtx <= 0 && len(requires) == 0 {
214+ return nil
198215 }
199216 if args.Model != "" {
200217 model, err := provider.FindModel(args.Provider, args.Model)
@@ -205,7 +222,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
205222 return nil
206223 }
207224 if !compatible(model) {
208- return fmt.Errorf("agent requires at least %d context tokens; %s has %d", minCtx, model.ID, model.ContextWindow)
225+ return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
209226 }
210227 return nil
211228 }
@@ -231,7 +248,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
231248 return nil
232249 }
233250 }
234- return fmt.Errorf("agent requires at least %d context tokens; no catalog model qualifies", minCtx)
251+ return fmt.Errorf("no catalog model satisfies the agent requirements")
235252 }
236253
237254 func prepareRuntimeCatalog() {
@@ -258,8 +275,8 @@ func zotPack(dir, out string) error {
258275 if _, err := readZotManifest(abs); err != nil {
259276 return err
260277 }
261- if _, err := os.Stat(filepath.Join(abs, "AGENT.md")); err != nil {
262- return fmt.Errorf("AGENT.md is required: %w", err)
278+ if err := validateZotfileDir(abs); err != nil {
279+ return err
263280 }
264281 if out == "" {
265282 m, _ := readZotManifest(abs)
@@ -309,6 +326,9 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
309326 if err != nil {
310327 return zotfileLoaded{}, nil, err
311328 }
329+ if err := validateZotfileDir(abs); err != nil {
330+ return zotfileLoaded{}, nil, err
331+ }
312332 return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
313333 }
314334 tmp, err := os.MkdirTemp("", "zotfile-*")
@@ -326,6 +346,10 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
326346 cleanup()
327347 return zotfileLoaded{}, nil, err
328348 }
349+ if err := validateZotfileDir(tmp); err != nil {
350+ cleanup()
351+ return zotfileLoaded{}, nil, err
352+ }
329353 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
330354 }
331355
@@ -341,19 +365,60 @@ func readZotManifest(dir string) (ZotfileManifest, error) {
341365 if m.Zotfile != 1 {
342366 return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
343367 }
344- if strings.TrimSpace(m.Name) == "" {
368+ name := strings.TrimSpace(m.Name)
369+ if name == "" {
345370 return m, fmt.Errorf("manifest name is required")
346371 }
372+ if name != strings.ToLower(name) || safeAgentName(name) != name {
373+ return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
374+ }
375+ if len(m.Permissions.Net.Allow) > 0 {
376+ return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
377+ }
378+ if len(m.Permissions.Env.Read) > 0 {
379+ return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
380+ }
381+ mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
382+ if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
383+ return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
384+ }
385+ if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
386+ return m, fmt.Errorf("bash allowlist mode requires at least one command")
387+ }
347388 return m, nil
348389 }
349390
391+func validateZotfileDir(dir string) error {
392+ st, err := os.Stat(filepath.Join(dir, "AGENT.md"))
393+ if err != nil || !st.Mode().IsRegular() {
394+ return fmt.Errorf("AGENT.md is required")
395+ }
396+ if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
397+ return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
398+ }
399+ return nil
400+}
401+
402+const (
403+ maxZotfileCompressedSize = 100 << 20
404+ maxZotfileEntrySize = 64 << 20
405+ maxZotfileExpandedSize = 256 << 20
406+)
407+
350408 func unpackZotArchive(path, dst string) (string, error) {
409+ info, err := os.Stat(path)
410+ if err != nil {
411+ return "", err
412+ }
413+ if info.Size() > maxZotfileCompressedSize {
414+ return "", fmt.Errorf("zotfile exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
415+ }
351416 b, err := os.ReadFile(path)
352417 if err != nil {
353418 return "", err
354419 }
355420 digest := sha256.Sum256(b)
356- r, err := zstd.NewReader(bytes.NewReader(b))
421+ r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize))
357422 if err != nil {
358423 // Development fallback for older experiments.
359424 gr, gerr := gzip.NewReader(bytes.NewReader(b))
@@ -369,6 +434,7 @@ func unpackZotArchive(path, dst string) (string, error) {
369434
370435 func untar(r io.Reader, dst string) error {
371436 tr := tar.NewReader(r)
437+ var expanded int64
372438 for {
373439 h, err := tr.Next()
374440 if err == io.EOF {
@@ -382,6 +448,10 @@ func untar(r io.Reader, dst string) error {
382448 return fmt.Errorf("unsafe path in archive: %s", h.Name)
383449 }
384450 path := filepath.Join(dst, name)
451+ if h.Size < 0 || h.Size > maxZotfileEntrySize || expanded+h.Size > maxZotfileExpandedSize {
452+ return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
453+ }
454+ expanded += h.Size
385455 switch h.Typeflag {
386456 case tar.TypeDir:
387457 if err := os.MkdirAll(path, 0o755); err != nil {
@@ -475,7 +545,15 @@ func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
475545 return nil
476546 }
477547
478-func checkZotfileRequirements(zf zotfileLoaded) error {
548+func checkZotfileRequirements(zf zotfileLoaded, version string) error {
549+ if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" {
550+ if versionOnly(version) == "0.0.0" {
551+ return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min)
552+ }
553+ if versionLess(version, min) {
554+ return fmt.Errorf("agent requires zot %s or newer; running %s", min, versionOnly(version))
555+ }
556+ }
479557 if len(zf.Manifest.Requirements.OS) > 0 {
480558 ok := false
481559 for _, osName := range zf.Manifest.Requirements.OS {
@@ -504,6 +582,15 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
504582 if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
505583 return nil
506584 }
585+ // "ask" deliberately requires approval on every launch. Other consent
586+ // is durable only for this exact artifact digest, so any package change
587+ // causes a fresh prompt.
588+ consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
589+ if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
590+ if _, err := os.Stat(consentPath); err == nil {
591+ return nil
592+ }
593+ }
507594 fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
508595 fmt.Print(permissionSummary(perms))
509596 if !term.IsTerminal(int(os.Stdin.Fd())) {
@@ -516,6 +603,17 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
516603 if answer != "y" && answer != "yes" {
517604 return fmt.Errorf("declined")
518605 }
606+ if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
607+ return nil
608+ }
609+ if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
610+ return fmt.Errorf("save agent consent: %w", err)
611+ }
612+ receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
613+ data, _ := json.MarshalIndent(receipt, "", " ")
614+ if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
615+ return fmt.Errorf("save agent consent: %w", err)
616+ }
519617 return nil
520618 }
521619
@@ -523,9 +621,13 @@ func permissionSummary(p tools.PermissionSet) string {
523621 var sb strings.Builder
524622 if len(p.FS.Read) > 0 {
525623 fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))
624+ } else {
625+ fmt.Fprintln(&sb, " fs read: none")
526626 }
527627 if len(p.FS.Write) > 0 {
528628 fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))
629+ } else {
630+ fmt.Fprintln(&sb, " fs write: none")
529631 }
530632 mode := p.Bash.Mode
531633 if mode == "" {
@@ -540,7 +642,7 @@ func permissionSummary(p tools.PermissionSet) string {
540642 fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
541643 }
542644 if len(p.Env.Read) > 0 {
543- fmt.Fprintf(&sb, " env read: %s\n", strings.Join(p.Env.Read, ", "))
645+ fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", "))
544646 }
545647 return sb.String()
546648 }
@@ -117,7 +117,7 @@ func runLocalZotfile(ref string, args Args, version string) error {
117 if err != nil {117 if err != nil {
118 return err118 return err
119 }119 }
120- if err := checkZotfileRequirements(zf); err != nil {120+ if err := checkZotfileRequirements(zf, version); err != nil {
121 return err121 return err
122 }122 }
123 agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")123 agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
@@ -144,9 +144,6 @@ func runLocalZotfile(ref string, args Args, version string) error {
144 if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {144 if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
145 args.Prompt = *zf.Manifest.Entry.DefaultPrompt145 args.Prompt = *zf.Manifest.Entry.DefaultPrompt
146 }146 }
147- if exts := bundledExtensionDirs(filepath.Join(zf.Dir, "extensions")); len(exts) > 0 {
148- args.Exts = append(args.Exts, exts...)
149- }
150 if dirExists(filepath.Join(zf.Dir, "skills")) {147 if dirExists(filepath.Join(zf.Dir, "skills")) {
151 old := os.Getenv("ZOT_AGENT_SKILLS")148 old := os.Getenv("ZOT_AGENT_SKILLS")
152 v := filepath.Join(zf.Dir, "skills")149 v := filepath.Join(zf.Dir, "skills")
@@ -190,11 +187,31 @@ func zotInspect(ref string) error {
190 187
191 func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {188 func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
192 minCtx := m.Model.MinContext189 minCtx := m.Model.MinContext
193- if minCtx <= 0 {190+ requires := map[string]bool{}
194- return nil191+ for _, capability := range m.Model.Requires {
192+ requires[strings.ToLower(strings.TrimSpace(capability))] = true
193+ }
194+ for capability := range requires {
195+ if capability != "tools" && capability != "vision" && capability != "reasoning" {
196+ return fmt.Errorf("unsupported model requirement %q", capability)
197+ }
198+ }
199+ if m.Model.MinTier != "" {
200+ return fmt.Errorf("model.min_tier is not supported by this zot version")
195 }201 }
196 compatible := func(model provider.Model) bool {202 compatible := func(model provider.Model) bool {
197- return model.ContextWindow >= minCtx203+ if model.ContextWindow < minCtx {
204+ return false
205+ }
206+ if requires["reasoning"] && !model.Reasoning {
207+ return false
208+ }
209+ // Every model exposed by the current catalog supports text and tools.
210+ // Vision support is not represented in Model yet, so fail closed.
211+ return !requires["vision"]
212+ }
213+ if minCtx <= 0 && len(requires) == 0 {
214+ return nil
198 }215 }
199 if args.Model != "" {216 if args.Model != "" {
200 model, err := provider.FindModel(args.Provider, args.Model)217 model, err := provider.FindModel(args.Provider, args.Model)
@@ -205,7 +222,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
205 return nil222 return nil
206 }223 }
207 if !compatible(model) {224 if !compatible(model) {
208- return fmt.Errorf("agent requires at least %d context tokens; %s has %d", minCtx, model.ID, model.ContextWindow)225+ return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
209 }226 }
210 return nil227 return nil
211 }228 }
@@ -231,7 +248,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
231 return nil248 return nil
232 }249 }
233 }250 }
234- return fmt.Errorf("agent requires at least %d context tokens; no catalog model qualifies", minCtx)251+ return fmt.Errorf("no catalog model satisfies the agent requirements")
235 }252 }
236 253
237 func prepareRuntimeCatalog() {254 func prepareRuntimeCatalog() {
@@ -258,8 +275,8 @@ func zotPack(dir, out string) error {
258 if _, err := readZotManifest(abs); err != nil {275 if _, err := readZotManifest(abs); err != nil {
259 return err276 return err
260 }277 }
261- if _, err := os.Stat(filepath.Join(abs, "AGENT.md")); err != nil {278+ if err := validateZotfileDir(abs); err != nil {
262- return fmt.Errorf("AGENT.md is required: %w", err)279+ return err
263 }280 }
264 if out == "" {281 if out == "" {
265 m, _ := readZotManifest(abs)282 m, _ := readZotManifest(abs)
@@ -309,6 +326,9 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
309 if err != nil {326 if err != nil {
310 return zotfileLoaded{}, nil, err327 return zotfileLoaded{}, nil, err
311 }328 }
329+ if err := validateZotfileDir(abs); err != nil {
330+ return zotfileLoaded{}, nil, err
331+ }
312 return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil332 return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
313 }333 }
314 tmp, err := os.MkdirTemp("", "zotfile-*")334 tmp, err := os.MkdirTemp("", "zotfile-*")
@@ -326,6 +346,10 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) {
326 cleanup()346 cleanup()
327 return zotfileLoaded{}, nil, err347 return zotfileLoaded{}, nil, err
328 }348 }
349+ if err := validateZotfileDir(tmp); err != nil {
350+ cleanup()
351+ return zotfileLoaded{}, nil, err
352+ }
329 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil353 return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
330 }354 }
331 355
@@ -341,19 +365,60 @@ func readZotManifest(dir string) (ZotfileManifest, error) {
341 if m.Zotfile != 1 {365 if m.Zotfile != 1 {
342 return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)366 return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
343 }367 }
344- if strings.TrimSpace(m.Name) == "" {368+ name := strings.TrimSpace(m.Name)
369+ if name == "" {
345 return m, fmt.Errorf("manifest name is required")370 return m, fmt.Errorf("manifest name is required")
346 }371 }
372+ if name != strings.ToLower(name) || safeAgentName(name) != name {
373+ return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
374+ }
375+ if len(m.Permissions.Net.Allow) > 0 {
376+ return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
377+ }
378+ if len(m.Permissions.Env.Read) > 0 {
379+ return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
380+ }
381+ mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
382+ if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
383+ return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
384+ }
385+ if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
386+ return m, fmt.Errorf("bash allowlist mode requires at least one command")
387+ }
347 return m, nil388 return m, nil
348 }389 }
349 390
391+func validateZotfileDir(dir string) error {
392+ st, err := os.Stat(filepath.Join(dir, "AGENT.md"))
393+ if err != nil || !st.Mode().IsRegular() {
394+ return fmt.Errorf("AGENT.md is required")
395+ }
396+ if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
397+ return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
398+ }
399+ return nil
400+}
401+
402+const (
403+ maxZotfileCompressedSize = 100 << 20
404+ maxZotfileEntrySize = 64 << 20
405+ maxZotfileExpandedSize = 256 << 20
406+)
407+
350 func unpackZotArchive(path, dst string) (string, error) {408 func unpackZotArchive(path, dst string) (string, error) {
409+ info, err := os.Stat(path)
410+ if err != nil {
411+ return "", err
412+ }
413+ if info.Size() > maxZotfileCompressedSize {
414+ return "", fmt.Errorf("zotfile exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
415+ }
351 b, err := os.ReadFile(path)416 b, err := os.ReadFile(path)
352 if err != nil {417 if err != nil {
353 return "", err418 return "", err
354 }419 }
355 digest := sha256.Sum256(b)420 digest := sha256.Sum256(b)
356- r, err := zstd.NewReader(bytes.NewReader(b))421+ r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize))
357 if err != nil {422 if err != nil {
358 // Development fallback for older experiments.423 // Development fallback for older experiments.
359 gr, gerr := gzip.NewReader(bytes.NewReader(b))424 gr, gerr := gzip.NewReader(bytes.NewReader(b))
@@ -369,6 +434,7 @@ func unpackZotArchive(path, dst string) (string, error) {
369 434
370 func untar(r io.Reader, dst string) error {435 func untar(r io.Reader, dst string) error {
371 tr := tar.NewReader(r)436 tr := tar.NewReader(r)
437+ var expanded int64
372 for {438 for {
373 h, err := tr.Next()439 h, err := tr.Next()
374 if err == io.EOF {440 if err == io.EOF {
@@ -382,6 +448,10 @@ func untar(r io.Reader, dst string) error {
382 return fmt.Errorf("unsafe path in archive: %s", h.Name)448 return fmt.Errorf("unsafe path in archive: %s", h.Name)
383 }449 }
384 path := filepath.Join(dst, name)450 path := filepath.Join(dst, name)
451+ if h.Size < 0 || h.Size > maxZotfileEntrySize || expanded+h.Size > maxZotfileExpandedSize {
452+ return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
453+ }
454+ expanded += h.Size
385 switch h.Typeflag {455 switch h.Typeflag {
386 case tar.TypeDir:456 case tar.TypeDir:
387 if err := os.MkdirAll(path, 0o755); err != nil {457 if err := os.MkdirAll(path, 0o755); err != nil {
@@ -475,7 +545,15 @@ func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
475 return nil545 return nil
476 }546 }
477 547
478-func checkZotfileRequirements(zf zotfileLoaded) error {548+func checkZotfileRequirements(zf zotfileLoaded, version string) error {
549+ if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" {
550+ if versionOnly(version) == "0.0.0" {
551+ return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min)
552+ }
553+ if versionLess(version, min) {
554+ return fmt.Errorf("agent requires zot %s or newer; running %s", min, versionOnly(version))
555+ }
556+ }
479 if len(zf.Manifest.Requirements.OS) > 0 {557 if len(zf.Manifest.Requirements.OS) > 0 {
480 ok := false558 ok := false
481 for _, osName := range zf.Manifest.Requirements.OS {559 for _, osName := range zf.Manifest.Requirements.OS {
@@ -504,6 +582,15 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
504 if os.Getenv("ZOT_AGENT_CONSENT") == "1" {582 if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
505 return nil583 return nil
506 }584 }
585+ // "ask" deliberately requires approval on every launch. Other consent
586+ // is durable only for this exact artifact digest, so any package change
587+ // causes a fresh prompt.
588+ consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
589+ if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
590+ if _, err := os.Stat(consentPath); err == nil {
591+ return nil
592+ }
593+ }
507 fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)594 fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
508 fmt.Print(permissionSummary(perms))595 fmt.Print(permissionSummary(perms))
509 if !term.IsTerminal(int(os.Stdin.Fd())) {596 if !term.IsTerminal(int(os.Stdin.Fd())) {
@@ -516,6 +603,17 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
516 if answer != "y" && answer != "yes" {603 if answer != "y" && answer != "yes" {
517 return fmt.Errorf("declined")604 return fmt.Errorf("declined")
518 }605 }
606+ if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
607+ return nil
608+ }
609+ if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
610+ return fmt.Errorf("save agent consent: %w", err)
611+ }
612+ receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
613+ data, _ := json.MarshalIndent(receipt, "", " ")
614+ if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
615+ return fmt.Errorf("save agent consent: %w", err)
616+ }
519 return nil617 return nil
520 }618 }
521 619
@@ -523,9 +621,13 @@ func permissionSummary(p tools.PermissionSet) string {
523 var sb strings.Builder621 var sb strings.Builder
524 if len(p.FS.Read) > 0 {622 if len(p.FS.Read) > 0 {
525 fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))623 fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))
624+ } else {
625+ fmt.Fprintln(&sb, " fs read: none")
526 }626 }
527 if len(p.FS.Write) > 0 {627 if len(p.FS.Write) > 0 {
528 fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))628 fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))
629+ } else {
630+ fmt.Fprintln(&sb, " fs write: none")
529 }631 }
530 mode := p.Bash.Mode632 mode := p.Bash.Mode
531 if mode == "" {633 if mode == "" {
@@ -540,7 +642,7 @@ func permissionSummary(p tools.PermissionSet) string {
540 fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))642 fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
541 }643 }
542 if len(p.Env.Read) > 0 {644 if len(p.Env.Read) > 0 {
543- fmt.Fprintf(&sb, " env read: %s\n", strings.Join(p.Env.Read, ", "))645+ fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", "))
544 }646 }
545 return sb.String()647 return sb.String()
546 }648 }
A packages/agent/zotfile_test.go +108 -0
@@ -0,0 +1,108 @@
1+package agent
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/patriceckhart/zot/packages/agent/tools"
12+)
13+
14+func writeTestZotfile(t *testing.T, manifest string) string {
15+ t.Helper()
16+ dir := t.TempDir()
17+ if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil {
21+ t.Fatal(err)
22+ }
23+ return dir
24+}
25+
26+func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
27+ for _, field := range []string{
28+ `"net":{"allow":["example.com"]}`,
29+ `"env":{"read":["HOME"]}`,
30+ } {
31+ dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`)
32+ if _, _, err := loadZotfile(dir); err == nil {
33+ t.Fatalf("manifest with %s was accepted", field)
34+ }
35+ }
36+}
37+
38+func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) {
39+ for _, name := range []string{"...", "Name", "two words", "a/b"} {
40+ dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`)
41+ if _, _, err := loadZotfile(dir); err == nil {
42+ t.Fatalf("unsafe manifest name %q was accepted", name)
43+ }
44+ }
45+}
46+
47+func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) {
48+ dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`)
49+ ext := filepath.Join(dir, "extensions", "bad")
50+ if err := os.MkdirAll(ext, 0o700); err != nil {
51+ t.Fatal(err)
52+ }
53+ if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
54+ t.Fatal(err)
55+ }
56+ if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
57+ t.Fatalf("unexpected error: %v", err)
58+ }
59+}
60+
61+func TestCheckZotfileMinVersion(t *testing.T) {
62+ var zf zotfileLoaded
63+ zf.Manifest.Runtime.MinZot = "0.3.0"
64+ if err := checkZotfileRequirements(zf, "0.2.75"); err == nil {
65+ t.Fatal("old zot version accepted")
66+ }
67+ if err := checkZotfileRequirements(zf, "0.3.0"); err != nil {
68+ t.Fatalf("minimum version rejected: %v", err)
69+ }
70+}
71+
72+func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
73+ var m ZotfileManifest
74+ m.Model.MinTier = "frontier"
75+ if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
76+ t.Fatal("unsupported min_tier was ignored")
77+ }
78+ m.Model.MinTier = ""
79+ m.Model.Requires = []string{"audio"}
80+ if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
81+ t.Fatal("unsupported capability was ignored")
82+ }
83+}
84+
85+func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
86+ makeTar := func(name string, size int64) []byte {
87+ var buf bytes.Buffer
88+ tw := tar.NewWriter(&buf)
89+ if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
90+ t.Fatal(err)
91+ }
92+ _ = tw.Close()
93+ return buf.Bytes()
94+ }
95+ if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
96+ t.Fatal("path traversal accepted")
97+ }
98+ if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil {
99+ t.Fatal("oversized entry accepted")
100+ }
101+}
102+
103+func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
104+ got := permissionSummary(tools.PermissionSet{})
105+ if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
106+ t.Fatalf("summary did not show denied scopes:\n%s", got)
107+ }
108+}
@@ -0,0 +1,108 @@
1+package agent
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/patriceckhart/zot/packages/agent/tools"
12+)
13+
14+func writeTestZotfile(t *testing.T, manifest string) string {
15+ t.Helper()
16+ dir := t.TempDir()
17+ if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil {
21+ t.Fatal(err)
22+ }
23+ return dir
24+}
25+
26+func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
27+ for _, field := range []string{
28+ `"net":{"allow":["example.com"]}`,
29+ `"env":{"read":["HOME"]}`,
30+ } {
31+ dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`)
32+ if _, _, err := loadZotfile(dir); err == nil {
33+ t.Fatalf("manifest with %s was accepted", field)
34+ }
35+ }
36+}
37+
38+func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) {
39+ for _, name := range []string{"...", "Name", "two words", "a/b"} {
40+ dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`)
41+ if _, _, err := loadZotfile(dir); err == nil {
42+ t.Fatalf("unsafe manifest name %q was accepted", name)
43+ }
44+ }
45+}
46+
47+func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) {
48+ dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`)
49+ ext := filepath.Join(dir, "extensions", "bad")
50+ if err := os.MkdirAll(ext, 0o700); err != nil {
51+ t.Fatal(err)
52+ }
53+ if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
54+ t.Fatal(err)
55+ }
56+ if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
57+ t.Fatalf("unexpected error: %v", err)
58+ }
59+}
60+
61+func TestCheckZotfileMinVersion(t *testing.T) {
62+ var zf zotfileLoaded
63+ zf.Manifest.Runtime.MinZot = "0.3.0"
64+ if err := checkZotfileRequirements(zf, "0.2.75"); err == nil {
65+ t.Fatal("old zot version accepted")
66+ }
67+ if err := checkZotfileRequirements(zf, "0.3.0"); err != nil {
68+ t.Fatalf("minimum version rejected: %v", err)
69+ }
70+}
71+
72+func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
73+ var m ZotfileManifest
74+ m.Model.MinTier = "frontier"
75+ if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
76+ t.Fatal("unsupported min_tier was ignored")
77+ }
78+ m.Model.MinTier = ""
79+ m.Model.Requires = []string{"audio"}
80+ if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
81+ t.Fatal("unsupported capability was ignored")
82+ }
83+}
84+
85+func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
86+ makeTar := func(name string, size int64) []byte {
87+ var buf bytes.Buffer
88+ tw := tar.NewWriter(&buf)
89+ if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
90+ t.Fatal(err)
91+ }
92+ _ = tw.Close()
93+ return buf.Bytes()
94+ }
95+ if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
96+ t.Fatal("path traversal accepted")
97+ }
98+ if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil {
99+ t.Fatal("oversized entry accepted")
100+ }
101+}
102+
103+func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
104+ got := permissionSummary(tools.PermissionSet{})
105+ if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
106+ t.Fatalf("summary did not show denied scopes:\n%s", got)
107+ }
108+}

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close