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 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | |
| 7 | 7 | github.com/google/uuid v1.6.0 | |
| 8 | + | github.com/klauspost/compress v1.19.0 | |
| 8 | 9 | github.com/mattn/go-runewidth v0.0.16 | |
| 9 | 10 | github.com/sahilm/fuzzy v0.1.1 | |
| 10 | 11 | golang.org/x/image v0.43.0 | |
| @@ -14,7 +15,6 @@ require ( | |||
| 14 | 15 | ||
| 15 | 16 | require ( | |
| 16 | 17 | github.com/dlclark/regexp2 v1.11.5 // indirect | |
| 17 | - | github.com/klauspost/compress v1.19.0 // indirect | |
| 18 | 18 | github.com/kylelemons/godebug v1.1.0 // indirect | |
| 19 | 19 | github.com/rivo/uniseg v0.2.0 // indirect | |
| 20 | 20 | ) | |
| @@ -5,6 +5,7 @@ go 1.25.0 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/alecthomas/chroma/v2 v2.23.1 | 6 | github.com/alecthomas/chroma/v2 v2.23.1 | ||
| 7 | github.com/google/uuid v1.6.0 | 7 | github.com/google/uuid v1.6.0 | ||
| 8 | + | github.com/klauspost/compress v1.19.0 | |||
| 8 | github.com/mattn/go-runewidth v0.0.16 | 9 | github.com/mattn/go-runewidth v0.0.16 | ||
| 9 | github.com/sahilm/fuzzy v0.1.1 | 10 | github.com/sahilm/fuzzy v0.1.1 | ||
| 10 | golang.org/x/image v0.43.0 | 11 | 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 // indirect | 17 | 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 // indirect | 18 | github.com/kylelemons/godebug v1.1.0 // indirect | ||
| 19 | github.com/rivo/uniseg v0.2.0 // indirect | 19 | 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 { | |||
| 34 | 34 | } | |
| 35 | 35 | 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 | 42 | for i, v := range p.FS.Read { | |
| 38 | 43 | p.FS.Read[i] = expand(v) | |
| 39 | 44 | } | |
| @@ -54,9 +59,12 @@ func (s *Sandbox) CheckReadPath(path string) error { | |||
| 54 | 59 | if err := s.CheckPath(path); err != nil { | |
| 55 | 60 | 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 | 63 | 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 | 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 | 72 | if err := s.CheckPath(path); err != nil { | |
| 65 | 73 | 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 | 76 | 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 | 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 | 110 | case "none": | |
| 100 | 111 | return fmt.Errorf("permission denied: this agent has no bash permission") | |
| 101 | 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 | 115 | return nil | |
| 103 | 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 | 120 | names := commandNames(cmd) | |
| 105 | 121 | if len(names) == 0 { | |
| 106 | 122 | return fmt.Errorf("permission denied: empty command") | |
| 107 | 123 | } | |
| 108 | 124 | allowed := map[string]bool{} | |
| 109 | 125 | for _, name := range s.Permissions.Bash.Allow { | |
| 110 | - | allowed[name] = true | |
| 126 | + | allowed[filepath.Base(strings.TrimSpace(name))] = true | |
| 111 | 127 | } | |
| 112 | 128 | for _, name := range names { | |
| 113 | 129 | if !allowed[name] { | |
| @@ -34,6 +34,11 @@ func (p PermissionSet) Expand(workspace, agentData string) PermissionSet { | |||||
| 34 | } | 34 | } | ||
| 35 | return s | 35 | 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 err | 60 | 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 nil | 63 | 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 err | 73 | 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 nil | 76 | 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 nil | 115 | 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] = true | 126 | + | 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 { | |||
| 117 | 117 | if err != nil { | |
| 118 | 118 | return err | |
| 119 | 119 | } | |
| 120 | - | if err := checkZotfileRequirements(zf); err != nil { | |
| 120 | + | if err := checkZotfileRequirements(zf, version); err != nil { | |
| 121 | 121 | return err | |
| 122 | 122 | } | |
| 123 | 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 | 144 | if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil { | |
| 145 | 145 | 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 | 147 | if dirExists(filepath.Join(zf.Dir, "skills")) { | |
| 151 | 148 | old := os.Getenv("ZOT_AGENT_SKILLS") | |
| 152 | 149 | v := filepath.Join(zf.Dir, "skills") | |
| @@ -190,11 +187,31 @@ func zotInspect(ref string) error { | |||
| 190 | 187 | ||
| 191 | 188 | func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error { | |
| 192 | 189 | 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") | |
| 195 | 201 | } | |
| 196 | 202 | 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 | |
| 198 | 215 | } | |
| 199 | 216 | if args.Model != "" { | |
| 200 | 217 | model, err := provider.FindModel(args.Provider, args.Model) | |
| @@ -205,7 +222,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error { | |||
| 205 | 222 | return nil | |
| 206 | 223 | } | |
| 207 | 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 | 227 | return nil | |
| 211 | 228 | } | |
| @@ -231,7 +248,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error { | |||
| 231 | 248 | 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 | 254 | func prepareRuntimeCatalog() { | |
| @@ -258,8 +275,8 @@ func zotPack(dir, out string) error { | |||
| 258 | 275 | if _, err := readZotManifest(abs); err != nil { | |
| 259 | 276 | return err | |
| 260 | 277 | } | |
| 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 | |
| 263 | 280 | } | |
| 264 | 281 | if out == "" { | |
| 265 | 282 | m, _ := readZotManifest(abs) | |
| @@ -309,6 +326,9 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) { | |||
| 309 | 326 | if err != nil { | |
| 310 | 327 | return zotfileLoaded{}, nil, err | |
| 311 | 328 | } | |
| 329 | + | if err := validateZotfileDir(abs); err != nil { | |
| 330 | + | return zotfileLoaded{}, nil, err | |
| 331 | + | } | |
| 312 | 332 | return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil | |
| 313 | 333 | } | |
| 314 | 334 | tmp, err := os.MkdirTemp("", "zotfile-*") | |
| @@ -326,6 +346,10 @@ func loadZotfile(ref string) (zotfileLoaded, func(), error) { | |||
| 326 | 346 | cleanup() | |
| 327 | 347 | return zotfileLoaded{}, nil, err | |
| 328 | 348 | } | |
| 349 | + | if err := validateZotfileDir(tmp); err != nil { | |
| 350 | + | cleanup() | |
| 351 | + | return zotfileLoaded{}, nil, err | |
| 352 | + | } | |
| 329 | 353 | 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 | 365 | if m.Zotfile != 1 { | |
| 342 | 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 | 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 | 388 | 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 | 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 | 416 | b, err := os.ReadFile(path) | |
| 352 | 417 | if err != nil { | |
| 353 | 418 | return "", err | |
| 354 | 419 | } | |
| 355 | 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 | 422 | if err != nil { | |
| 358 | 423 | // Development fallback for older experiments. | |
| 359 | 424 | gr, gerr := gzip.NewReader(bytes.NewReader(b)) | |
| @@ -369,6 +434,7 @@ func unpackZotArchive(path, dst string) (string, error) { | |||
| 369 | 434 | ||
| 370 | 435 | func untar(r io.Reader, dst string) error { | |
| 371 | 436 | tr := tar.NewReader(r) | |
| 437 | + | var expanded int64 | |
| 372 | 438 | for { | |
| 373 | 439 | h, err := tr.Next() | |
| 374 | 440 | if err == io.EOF { | |
| @@ -382,6 +448,10 @@ func untar(r io.Reader, dst string) error { | |||
| 382 | 448 | return fmt.Errorf("unsafe path in archive: %s", h.Name) | |
| 383 | 449 | } | |
| 384 | 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 | 455 | switch h.Typeflag { | |
| 386 | 456 | case tar.TypeDir: | |
| 387 | 457 | if err := os.MkdirAll(path, 0o755); err != nil { | |
| @@ -475,7 +545,15 @@ func writeCanonicalTar(root string, w io.Writer, exclude ...string) error { | |||
| 475 | 545 | 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 | 557 | if len(zf.Manifest.Requirements.OS) > 0 { | |
| 480 | 558 | ok := false | |
| 481 | 559 | for _, osName := range zf.Manifest.Requirements.OS { | |
| @@ -504,6 +582,15 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||
| 504 | 582 | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |
| 505 | 583 | 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 | 594 | fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version) | |
| 508 | 595 | fmt.Print(permissionSummary(perms)) | |
| 509 | 596 | if !term.IsTerminal(int(os.Stdin.Fd())) { | |
| @@ -516,6 +603,17 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error { | |||
| 516 | 603 | if answer != "y" && answer != "yes" { | |
| 517 | 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 | 617 | return nil | |
| 520 | 618 | } | |
| 521 | 619 | ||
| @@ -523,9 +621,13 @@ func permissionSummary(p tools.PermissionSet) string { | |||
| 523 | 621 | var sb strings.Builder | |
| 524 | 622 | if len(p.FS.Read) > 0 { | |
| 525 | 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 | 627 | if len(p.FS.Write) > 0 { | |
| 528 | 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 | 632 | mode := p.Bash.Mode | |
| 531 | 633 | if mode == "" { | |
| @@ -540,7 +642,7 @@ func permissionSummary(p tools.PermissionSet) string { | |||
| 540 | 642 | fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", ")) | |
| 541 | 643 | } | |
| 542 | 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 | 647 | return sb.String() | |
| 546 | 648 | } | |
| @@ -117,7 +117,7 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||||
| 117 | if err != nil { | 117 | if err != nil { | ||
| 118 | return err | 118 | return err | ||
| 119 | } | 119 | } | ||
| 120 | - | if err := checkZotfileRequirements(zf); err != nil { | 120 | + | if err := checkZotfileRequirements(zf, version); err != nil { |
| 121 | return err | 121 | 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.DefaultPrompt | 145 | 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.MinContext | 189 | minCtx := m.Model.MinContext | ||
| 193 | - | if minCtx <= 0 { | 190 | + | requires := map[string]bool{} |
| 194 | - | return nil | 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") | |||
| 195 | } | 201 | } | ||
| 196 | compatible := func(model provider.Model) bool { | 202 | 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 | |||
| 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 nil | 222 | 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 nil | 227 | return nil | ||
| 211 | } | 228 | } | ||
| @@ -231,7 +248,7 @@ func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error { | |||||
| 231 | return nil | 248 | 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 err | 276 | 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, err | 327 | 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, nil | 332 | 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, err | 347 | 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, nil | 353 | 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, nil | 388 | 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 "", err | 418 | 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 nil | 545 | 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 := false | 558 | 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 nil | 583 | 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 nil | 617 | return nil | ||
| 520 | } | 618 | } | ||
| 521 | 619 | ||||
| @@ -523,9 +621,13 @@ func permissionSummary(p tools.PermissionSet) string { | |||||
| 523 | var sb strings.Builder | 621 | 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.Mode | 632 | 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 | + | } | |||