detached 9559c57

Merge pull request #71 from patriceckhart/feature/zotfile-local-runtime

Add local Zotfile runtime
commit
9559c57f4da3bff5b25f3325db08ba4f714e47c5
author
Patric Eckhart · · 2026-07-12 11:52 UTC
committer
GitHub · · 2026-07-12 11:52 UTC
merge of
92b87ec + 0b19f96 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.76
15 files · +1089 -39
M go.mod +1 -0
@@ -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
@@ -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
M go.sum +2 -0 generated file: collapsed
M packages/agent/args.go +8 -0
@@ -6,6 +6,7 @@ import (
66 "strconv"
77 "strings"
88
9+ "github.com/patriceckhart/zot/packages/agent/tools"
910 "github.com/patriceckhart/zot/packages/tui"
1011 "golang.org/x/term"
1112 )
@@ -100,6 +101,13 @@ type Args struct {
100101 // swarm-spawned agent. Empty in every other mode. Set by
101102 // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
102103 SwarmAgent string
104+
105+ // AgentName/AgentDataDir/PermissionSet are populated by `zot run`
106+ // for local Zotfile agents. They scope sessions and enforce the
107+ // manifest's declared file/bash permissions.
108+ AgentName string
109+ AgentDataDir string
110+ PermissionSet *tools.PermissionSet
103111 }
104112
105113 // ParseArgs parses the process arguments (excluding argv[0]).
@@ -6,6 +6,7 @@ import (
6 "strconv"6 "strconv"
7 "strings"7 "strings"
8 8
9+ "github.com/patriceckhart/zot/packages/agent/tools"
9 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
10 "golang.org/x/term"11 "golang.org/x/term"
11 )12 )
@@ -100,6 +101,13 @@ type Args struct {
100 // swarm-spawned agent. Empty in every other mode. Set by101 // swarm-spawned agent. Empty in every other mode. Set by
101 // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.102 // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
102 SwarmAgent string103 SwarmAgent string
104+
105+ // AgentName/AgentDataDir/PermissionSet are populated by `zot run`
106+ // for local Zotfile agents. They scope sessions and enforce the
107+ // manifest's declared file/bash permissions.
108+ AgentName string
109+ AgentDataDir string
110+ PermissionSet *tools.PermissionSet
103 }111 }
104 112
105 // ParseArgs parses the process arguments (excluding argv[0]).113 // ParseArgs parses the process arguments (excluding argv[0]).
M packages/agent/build.go +6 -0
@@ -528,6 +528,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
528528 }
529529
530530 sandbox := tools.NewSandbox(args.CWD)
531+ if args.PermissionSet != nil {
532+ sandbox.SetPermissions(args.PermissionSet)
533+ }
531534 reg := buildToolRegistry(args, args.CWD, sandbox)
532535
533536 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
@@ -877,6 +880,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
877880 if s == nil || r == nil {
878881 return
879882 }
883+ if r.Sandbox != nil && r.Sandbox.Permissions != nil {
884+ s.Permissions = r.Sandbox.Permissions
885+ }
880886 r.Sandbox = s
881887 for name, t := range r.ToolRegistry {
882888 switch v := t.(type) {
@@ -528,6 +528,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
528 }528 }
529 529
530 sandbox := tools.NewSandbox(args.CWD)530 sandbox := tools.NewSandbox(args.CWD)
531+ if args.PermissionSet != nil {
532+ sandbox.SetPermissions(args.PermissionSet)
533+ }
531 reg := buildToolRegistry(args, args.CWD, sandbox)534 reg := buildToolRegistry(args, args.CWD, sandbox)
532 535
533 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())536 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
@@ -877,6 +880,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
877 if s == nil || r == nil {880 if s == nil || r == nil {
878 return881 return
879 }882 }
883+ if r.Sandbox != nil && r.Sandbox.Permissions != nil {
884+ s.Permissions = r.Sandbox.Permissions
885+ }
880 r.Sandbox = s886 r.Sandbox = s
881 for name, t := range r.ToolRegistry {887 for name, t := range r.ToolRegistry {
882 switch v := t.(type) {888 switch v := t.(type) {
M packages/agent/cli.go +33 -34
@@ -189,6 +189,13 @@ func Run(rawArgs []string, version string) error {
189189 if handled, err := runUpdateCommand(rawArgs, version); handled {
190190 return err
191191 }
192+ if handled, err := runZotfileCommand(rawArgs, version); handled {
193+ return err
194+ }
195+ return runWithArgsRaw(rawArgs, version)
196+}
197+
198+func runWithArgsRaw(rawArgs []string, version string) error {
192199 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
193200 // spawn the binary with a clean argv. Strip the leading 'rpc'
194201 // token and let the rest flow through the normal arg parser.
@@ -211,39 +218,18 @@ func Run(rawArgs []string, version string) error {
211218 }
212219 // Model catalog: load any cached discovery data before we inspect
213220 // the model list (list-models, print/json, interactive).
214- LoadCachedModels()
215- LoadUserModels()
216-
217- // Register custom provider names with the auth package so they
218- // participate in /login API-key flow and provider pickers.
219- if cps := provider.CustomProviders(); len(cps) > 0 {
220- names := make([]string, 0, len(cps))
221- for name := range cps {
222- if !isBuiltinProvider(name) {
223- names = append(names, name)
224- }
225- }
226- auth.SetExtraAPIKeyProviders(names)
227- }
228-
229- // Repair config.json so a stale (provider, model) pair from an
230- // interrupted /model switch can't strand the user with an
231- // "unknown model" error on the first turn. Runs before any UI
232- // renders so the status bar shows the post-repair pair, not the
233- // broken one. Silent on success.
234- ValidateAndRepairConfig()
221+ prepareRuntimeCatalog()
235222
236223 if args.ListModels {
237224 printModels()
238225 return nil
239226 }
240227
241- ctx := context.Background()
242-
243- // Kick an async refresh of the live model catalog. The first run of
244- // zot hits the network; subsequent runs within CacheTTL do nothing.
245- RefreshModelsAsync()
228+ return runWithArgs(args, version)
229+}
246230
231+func runWithArgs(args Args, version string) error {
232+ ctx := context.Background()
247233 switch args.Mode {
248234 case ModePrint:
249235 return runPrintMode(ctx, args, version)
@@ -835,6 +821,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
835821 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
836822 args.CWD = absPath
837823 r.CWD = absPath
824+ if args.PermissionSet != nil {
825+ expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
826+ args.PermissionSet = &expanded
827+ }
838828 if sharedSandbox != nil {
839829 sharedSandbox.Root = absPath
840830 if wasJailed {
@@ -857,8 +847,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
857847 // because /cd's semantics are "start fresh here", matching
858848 // what relaunching `zot --cwd <path>` would do today.
859849 if !args.NoSess {
860- core.PruneEmptySessions(ZotHome(), absPath)
861- newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version)
850+ newRoot := agentSessionsRoot(ZotHome(), args)
851+ core.PruneEmptySessions(newRoot, absPath)
852+ newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)
862853 if serr != nil {
863854 return fmt.Errorf("open session in %s: %v", absPath, serr)
864855 }
@@ -1149,6 +1140,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11491140 return runErr
11501141 }
11511142
1143+func agentSessionsRoot(root string, args Args) string {
1144+ if args.AgentName == "" {
1145+ return root
1146+ }
1147+ return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
1148+}
1149+
11521150 // openOrCreateSession returns a session for the run. sess may be nil
11531151 // with a nil error if session persistence is disabled.
11541152 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
@@ -1158,7 +1156,8 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
11581156 // Sweep meta-only files left over from older zot versions (and from
11591157 // any session that crashed before its first AppendMessage). Cheap;
11601158 // reads the first few bytes of each file in the cwd's session dir.
1161- core.PruneEmptySessions(ZotHome(), args.CWD)
1159+ sessionsRoot := agentSessionsRoot(ZotHome(), args)
1160+ core.PruneEmptySessions(sessionsRoot, args.CWD)
11621161 var (
11631162 s *core.Session
11641163 msgs []provider.Message
@@ -1180,12 +1179,12 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
11801179 msgs = nil
11811180 }
11821181 case args.Continue:
1183- latest := core.LatestSession(ZotHome(), args.CWD)
1182+ latest := core.LatestSession(sessionsRoot, args.CWD)
11841183 if latest != "" {
11851184 s, msgs, err = core.OpenSession(latest)
11861185 }
11871186 case args.Resume:
1188- picked, perr := pickSession(args.CWD)
1187+ picked, perr := pickSession(sessionsRoot, args.CWD)
11891188 if perr != nil {
11901189 return nil, perr
11911190 }
@@ -1204,11 +1203,11 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
12041203 }
12051204 return s, nil
12061205 }
1207- return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
1206+ return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
12081207 }
12091208
1210-func pickSession(cwd string) (string, error) {
1211- files := core.ListSessions(ZotHome(), cwd)
1209+func pickSession(root, cwd string) (string, error) {
1210+ files := core.ListSessions(root, cwd)
12121211 if len(files) == 0 {
12131212 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
12141213 return "", nil
@@ -189,6 +189,13 @@ func Run(rawArgs []string, version string) error {
189 if handled, err := runUpdateCommand(rawArgs, version); handled {189 if handled, err := runUpdateCommand(rawArgs, version); handled {
190 return err190 return err
191 }191 }
192+ if handled, err := runZotfileCommand(rawArgs, version); handled {
193+ return err
194+ }
195+ return runWithArgsRaw(rawArgs, version)
196+}
197+
198+func runWithArgsRaw(rawArgs []string, version string) error {
192 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can199 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
193 // spawn the binary with a clean argv. Strip the leading 'rpc'200 // spawn the binary with a clean argv. Strip the leading 'rpc'
194 // token and let the rest flow through the normal arg parser.201 // token and let the rest flow through the normal arg parser.
@@ -211,39 +218,18 @@ func Run(rawArgs []string, version string) error {
211 }218 }
212 // Model catalog: load any cached discovery data before we inspect219 // Model catalog: load any cached discovery data before we inspect
213 // the model list (list-models, print/json, interactive).220 // the model list (list-models, print/json, interactive).
214- LoadCachedModels()221+ prepareRuntimeCatalog()
215- LoadUserModels()
216-
217- // Register custom provider names with the auth package so they
218- // participate in /login API-key flow and provider pickers.
219- if cps := provider.CustomProviders(); len(cps) > 0 {
220- names := make([]string, 0, len(cps))
221- for name := range cps {
222- if !isBuiltinProvider(name) {
223- names = append(names, name)
224- }
225- }
226- auth.SetExtraAPIKeyProviders(names)
227- }
228-
229- // Repair config.json so a stale (provider, model) pair from an
230- // interrupted /model switch can't strand the user with an
231- // "unknown model" error on the first turn. Runs before any UI
232- // renders so the status bar shows the post-repair pair, not the
233- // broken one. Silent on success.
234- ValidateAndRepairConfig()
235 222
236 if args.ListModels {223 if args.ListModels {
237 printModels()224 printModels()
238 return nil225 return nil
239 }226 }
240 227
241- ctx := context.Background()228+ return runWithArgs(args, version)
242-229+}
243- // Kick an async refresh of the live model catalog. The first run of
244- // zot hits the network; subsequent runs within CacheTTL do nothing.
245- RefreshModelsAsync()
246 230
231+func runWithArgs(args Args, version string) error {
232+ ctx := context.Background()
247 switch args.Mode {233 switch args.Mode {
248 case ModePrint:234 case ModePrint:
249 return runPrintMode(ctx, args, version)235 return runPrintMode(ctx, args, version)
@@ -835,6 +821,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
835 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()821 wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
836 args.CWD = absPath822 args.CWD = absPath
837 r.CWD = absPath823 r.CWD = absPath
824+ if args.PermissionSet != nil {
825+ expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
826+ args.PermissionSet = &expanded
827+ }
838 if sharedSandbox != nil {828 if sharedSandbox != nil {
839 sharedSandbox.Root = absPath829 sharedSandbox.Root = absPath
840 if wasJailed {830 if wasJailed {
@@ -857,8 +847,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
857 // because /cd's semantics are "start fresh here", matching847 // because /cd's semantics are "start fresh here", matching
858 // what relaunching `zot --cwd <path>` would do today.848 // what relaunching `zot --cwd <path>` would do today.
859 if !args.NoSess {849 if !args.NoSess {
860- core.PruneEmptySessions(ZotHome(), absPath)850+ newRoot := agentSessionsRoot(ZotHome(), args)
861- newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version)851+ core.PruneEmptySessions(newRoot, absPath)
852+ newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)
862 if serr != nil {853 if serr != nil {
863 return fmt.Errorf("open session in %s: %v", absPath, serr)854 return fmt.Errorf("open session in %s: %v", absPath, serr)
864 }855 }
@@ -1149,6 +1140,13 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1149 return runErr1140 return runErr
1150 }1141 }
1151 1142
1143+func agentSessionsRoot(root string, args Args) string {
1144+ if args.AgentName == "" {
1145+ return root
1146+ }
1147+ return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
1148+}
1149+
1152 // openOrCreateSession returns a session for the run. sess may be nil1150 // openOrCreateSession returns a session for the run. sess may be nil
1153 // with a nil error if session persistence is disabled.1151 // with a nil error if session persistence is disabled.
1154 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {1152 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
@@ -1158,7 +1156,8 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1158 // Sweep meta-only files left over from older zot versions (and from1156 // Sweep meta-only files left over from older zot versions (and from
1159 // any session that crashed before its first AppendMessage). Cheap;1157 // any session that crashed before its first AppendMessage). Cheap;
1160 // reads the first few bytes of each file in the cwd's session dir.1158 // reads the first few bytes of each file in the cwd's session dir.
1161- core.PruneEmptySessions(ZotHome(), args.CWD)1159+ sessionsRoot := agentSessionsRoot(ZotHome(), args)
1160+ core.PruneEmptySessions(sessionsRoot, args.CWD)
1162 var (1161 var (
1163 s *core.Session1162 s *core.Session
1164 msgs []provider.Message1163 msgs []provider.Message
@@ -1180,12 +1179,12 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1180 msgs = nil1179 msgs = nil
1181 }1180 }
1182 case args.Continue:1181 case args.Continue:
1183- latest := core.LatestSession(ZotHome(), args.CWD)1182+ latest := core.LatestSession(sessionsRoot, args.CWD)
1184 if latest != "" {1183 if latest != "" {
1185 s, msgs, err = core.OpenSession(latest)1184 s, msgs, err = core.OpenSession(latest)
1186 }1185 }
1187 case args.Resume:1186 case args.Resume:
1188- picked, perr := pickSession(args.CWD)1187+ picked, perr := pickSession(sessionsRoot, args.CWD)
1189 if perr != nil {1188 if perr != nil {
1190 return nil, perr1189 return nil, perr
1191 }1190 }
@@ -1204,11 +1203,11 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1204 }1203 }
1205 return s, nil1204 return s, nil
1206 }1205 }
1207- return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)1206+ return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
1208 }1207 }
1209 1208
1210-func pickSession(cwd string) (string, error) {1209+func pickSession(root, cwd string) (string, error) {
1211- files := core.ListSessions(ZotHome(), cwd)1210+ files := core.ListSessions(root, cwd)
1212 if len(files) == 0 {1211 if len(files) == 0 {
1213 fmt.Fprintln(os.Stderr, "no sessions for", cwd)1212 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
1214 return "", nil1213 return "", nil
M packages/agent/skills/skills.go +5 -0
@@ -230,6 +230,11 @@ func searchDirs(zotHome, cwd, userHome string) []location {
230230 }
231231 out = append(out, location{dir: dir, label: label})
232232 }
233+ if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" {
234+ for _, dir := range filepath.SplitList(extra) {
235+ add(dir, "agent")
236+ }
237+ }
233238 if cwd != "" {
234239 add(filepath.Join(cwd, ".zot", "skills"), "project")
235240 }
@@ -230,6 +230,11 @@ func searchDirs(zotHome, cwd, userHome string) []location {
230 }230 }
231 out = append(out, location{dir: dir, label: label})231 out = append(out, location{dir: dir, label: label})
232 }232 }
233+ if extra := os.Getenv("ZOT_AGENT_SKILLS"); extra != "" {
234+ for _, dir := range filepath.SplitList(extra) {
235+ add(dir, "agent")
236+ }
237+ }
233 if cwd != "" {238 if cwd != "" {
234 add(filepath.Join(cwd, ".zot", "skills"), "project")239 add(filepath.Join(cwd, ".zot", "skills"), "project")
235 }240 }
M packages/agent/tools/bash.go +3 -0
@@ -52,6 +52,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
5252 if err := t.Sandbox.CheckCommand(a.Command); err != nil {
5353 return core.ToolResult{}, err
5454 }
55+ if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
56+ return core.ToolResult{}, err
57+ }
5558 cwd := t.CWD
5659 if cwd == "" {
5760 cwd, _ = os.Getwd()
@@ -52,6 +52,9 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
52 if err := t.Sandbox.CheckCommand(a.Command); err != nil {52 if err := t.Sandbox.CheckCommand(a.Command); err != nil {
53 return core.ToolResult{}, err53 return core.ToolResult{}, err
54 }54 }
55+ if err := t.Sandbox.CheckBashPermission(a.Command); err != nil {
56+ return core.ToolResult{}, err
57+ }
55 cwd := t.CWD58 cwd := t.CWD
56 if cwd == "" {59 if cwd == "" {
57 cwd, _ = os.Getwd()60 cwd, _ = os.Getwd()
M packages/agent/tools/edit.go +1 -1
@@ -48,7 +48,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
4848 return core.ToolResult{}, fmt.Errorf("at least one edit is required")
4949 }
5050 path := resolvePath(t.CWD, a.Path)
51- if err := t.Sandbox.CheckPath(path); err != nil {
51+ if err := t.Sandbox.CheckWritePath(path); err != nil {
5252 return core.ToolResult{}, err
5353 }
5454
@@ -48,7 +48,7 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
48 return core.ToolResult{}, fmt.Errorf("at least one edit is required")48 return core.ToolResult{}, fmt.Errorf("at least one edit is required")
49 }49 }
50 path := resolvePath(t.CWD, a.Path)50 path := resolvePath(t.CWD, a.Path)
51- if err := t.Sandbox.CheckPath(path); err != nil {51+ if err := t.Sandbox.CheckWritePath(path); err != nil {
52 return core.ToolResult{}, err52 return core.ToolResult{}, err
53 }53 }
54 54
A packages/agent/tools/permissions.go +168 -0
@@ -0,0 +1,168 @@
1+package tools
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// PermissionSet is the local-runtime permission contract for a packaged .zot agent.
10+type PermissionSet struct {
11+ FS struct {
12+ Read []string `json:"read"`
13+ Write []string `json:"write"`
14+ } `json:"fs"`
15+ Bash struct {
16+ Mode string `json:"mode"`
17+ Allow []string `json:"allow"`
18+ } `json:"bash"`
19+ Net struct {
20+ Allow []string `json:"allow"`
21+ } `json:"net"`
22+ Env struct {
23+ Read []string `json:"read"`
24+ } `json:"env"`
25+}
26+
27+// Expand resolves manifest variables into absolute local paths.
28+func (p PermissionSet) Expand(workspace, agentData string) PermissionSet {
29+ expand := func(s string) string {
30+ s = strings.ReplaceAll(s, "${workspace}", workspace)
31+ s = strings.ReplaceAll(s, "${agent_data}", agentData)
32+ if !filepath.IsAbs(s) && s != "" {
33+ s = filepath.Join(workspace, s)
34+ }
35+ return s
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...)
42+ for i, v := range p.FS.Read {
43+ p.FS.Read[i] = expand(v)
44+ }
45+ for i, v := range p.FS.Write {
46+ p.FS.Write[i] = expand(v)
47+ }
48+ return p
49+}
50+
51+func (s *Sandbox) SetPermissions(p *PermissionSet) {
52+ if s == nil {
53+ return
54+ }
55+ s.Permissions = p
56+}
57+
58+func (s *Sandbox) CheckReadPath(path string) error {
59+ if err := s.CheckPath(path); err != nil {
60+ return err
61+ }
62+ if s == nil || s.Permissions == nil {
63+ return nil
64+ }
65+ if len(s.Permissions.FS.Read) == 0 {
66+ return fmt.Errorf("permission denied: this agent has no filesystem read permission")
67+ }
68+ return checkScopedPath("read", path, s.Permissions.FS.Read)
69+}
70+
71+func (s *Sandbox) CheckWritePath(path string) error {
72+ if err := s.CheckPath(path); err != nil {
73+ return err
74+ }
75+ if s == nil || s.Permissions == nil {
76+ return nil
77+ }
78+ if len(s.Permissions.FS.Write) == 0 {
79+ return fmt.Errorf("permission denied: this agent has no filesystem write permission")
80+ }
81+ return checkScopedPath("write", path, s.Permissions.FS.Write)
82+}
83+
84+func checkScopedPath(op, path string, scopes []string) error {
85+ target, err := canonicalOrParent(path)
86+ if err != nil {
87+ return fmt.Errorf("permission path: %w", err)
88+ }
89+ for _, scope := range scopes {
90+ root, err := canonicalOrParent(scope)
91+ if err != nil {
92+ continue
93+ }
94+ if isUnder(root, target) {
95+ return nil
96+ }
97+ }
98+ return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)
99+}
100+
101+func (s *Sandbox) CheckBashPermission(cmd string) error {
102+ if s == nil || s.Permissions == nil {
103+ return nil
104+ }
105+ mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode))
106+ if mode == "" {
107+ mode = "none"
108+ }
109+ switch mode {
110+ case "none":
111+ return fmt.Errorf("permission denied: this agent has no bash permission")
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.
115+ return nil
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+ }
120+ names := commandNames(cmd)
121+ if len(names) == 0 {
122+ return fmt.Errorf("permission denied: empty command")
123+ }
124+ allowed := map[string]bool{}
125+ for _, name := range s.Permissions.Bash.Allow {
126+ allowed[filepath.Base(strings.TrimSpace(name))] = true
127+ }
128+ for _, name := range names {
129+ if !allowed[name] {
130+ return fmt.Errorf("permission denied: bash command %q is not in allowlist", name)
131+ }
132+ }
133+ return nil
134+ default:
135+ return fmt.Errorf("permission denied: unsupported bash mode %q", mode)
136+ }
137+}
138+
139+func commandNames(cmd string) []string {
140+ cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
141+ parts := strings.Split(cmd, ";")
142+ var out []string
143+ for _, part := range parts {
144+ name := firstCommandName(part)
145+ if name == "" || name == "cd" {
146+ continue
147+ }
148+ out = append(out, name)
149+ }
150+ return out
151+}
152+
153+func firstCommandName(cmd string) string {
154+ cmd = strings.TrimSpace(cmd)
155+ if cmd == "" {
156+ return ""
157+ }
158+ fields := strings.Fields(cmd)
159+ if len(fields) == 0 {
160+ return ""
161+ }
162+ name := fields[0]
163+ for strings.Contains(name, "=") && len(fields) > 1 {
164+ fields = fields[1:]
165+ name = fields[0]
166+ }
167+ return filepath.Base(strings.Trim(name, "'\""))
168+}
@@ -0,0 +1,168 @@
1+package tools
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// PermissionSet is the local-runtime permission contract for a packaged .zot agent.
10+type PermissionSet struct {
11+ FS struct {
12+ Read []string `json:"read"`
13+ Write []string `json:"write"`
14+ } `json:"fs"`
15+ Bash struct {
16+ Mode string `json:"mode"`
17+ Allow []string `json:"allow"`
18+ } `json:"bash"`
19+ Net struct {
20+ Allow []string `json:"allow"`
21+ } `json:"net"`
22+ Env struct {
23+ Read []string `json:"read"`
24+ } `json:"env"`
25+}
26+
27+// Expand resolves manifest variables into absolute local paths.
28+func (p PermissionSet) Expand(workspace, agentData string) PermissionSet {
29+ expand := func(s string) string {
30+ s = strings.ReplaceAll(s, "${workspace}", workspace)
31+ s = strings.ReplaceAll(s, "${agent_data}", agentData)
32+ if !filepath.IsAbs(s) && s != "" {
33+ s = filepath.Join(workspace, s)
34+ }
35+ return s
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...)
42+ for i, v := range p.FS.Read {
43+ p.FS.Read[i] = expand(v)
44+ }
45+ for i, v := range p.FS.Write {
46+ p.FS.Write[i] = expand(v)
47+ }
48+ return p
49+}
50+
51+func (s *Sandbox) SetPermissions(p *PermissionSet) {
52+ if s == nil {
53+ return
54+ }
55+ s.Permissions = p
56+}
57+
58+func (s *Sandbox) CheckReadPath(path string) error {
59+ if err := s.CheckPath(path); err != nil {
60+ return err
61+ }
62+ if s == nil || s.Permissions == nil {
63+ return nil
64+ }
65+ if len(s.Permissions.FS.Read) == 0 {
66+ return fmt.Errorf("permission denied: this agent has no filesystem read permission")
67+ }
68+ return checkScopedPath("read", path, s.Permissions.FS.Read)
69+}
70+
71+func (s *Sandbox) CheckWritePath(path string) error {
72+ if err := s.CheckPath(path); err != nil {
73+ return err
74+ }
75+ if s == nil || s.Permissions == nil {
76+ return nil
77+ }
78+ if len(s.Permissions.FS.Write) == 0 {
79+ return fmt.Errorf("permission denied: this agent has no filesystem write permission")
80+ }
81+ return checkScopedPath("write", path, s.Permissions.FS.Write)
82+}
83+
84+func checkScopedPath(op, path string, scopes []string) error {
85+ target, err := canonicalOrParent(path)
86+ if err != nil {
87+ return fmt.Errorf("permission path: %w", err)
88+ }
89+ for _, scope := range scopes {
90+ root, err := canonicalOrParent(scope)
91+ if err != nil {
92+ continue
93+ }
94+ if isUnder(root, target) {
95+ return nil
96+ }
97+ }
98+ return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)
99+}
100+
101+func (s *Sandbox) CheckBashPermission(cmd string) error {
102+ if s == nil || s.Permissions == nil {
103+ return nil
104+ }
105+ mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode))
106+ if mode == "" {
107+ mode = "none"
108+ }
109+ switch mode {
110+ case "none":
111+ return fmt.Errorf("permission denied: this agent has no bash permission")
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.
115+ return nil
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+ }
120+ names := commandNames(cmd)
121+ if len(names) == 0 {
122+ return fmt.Errorf("permission denied: empty command")
123+ }
124+ allowed := map[string]bool{}
125+ for _, name := range s.Permissions.Bash.Allow {
126+ allowed[filepath.Base(strings.TrimSpace(name))] = true
127+ }
128+ for _, name := range names {
129+ if !allowed[name] {
130+ return fmt.Errorf("permission denied: bash command %q is not in allowlist", name)
131+ }
132+ }
133+ return nil
134+ default:
135+ return fmt.Errorf("permission denied: unsupported bash mode %q", mode)
136+ }
137+}
138+
139+func commandNames(cmd string) []string {
140+ cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
141+ parts := strings.Split(cmd, ";")
142+ var out []string
143+ for _, part := range parts {
144+ name := firstCommandName(part)
145+ if name == "" || name == "cd" {
146+ continue
147+ }
148+ out = append(out, name)
149+ }
150+ return out
151+}
152+
153+func firstCommandName(cmd string) string {
154+ cmd = strings.TrimSpace(cmd)
155+ if cmd == "" {
156+ return ""
157+ }
158+ fields := strings.Fields(cmd)
159+ if len(fields) == 0 {
160+ return ""
161+ }
162+ name := fields[0]
163+ for strings.Contains(name, "=") && len(fields) > 1 {
164+ fields = fields[1:]
165+ name = fields[0]
166+ }
167+ return filepath.Base(strings.Trim(name, "'\""))
168+}
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/tools/read.go +1 -1
@@ -49,7 +49,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
4949 return core.ToolResult{}, fmt.Errorf("path is required")
5050 }
5151 path := resolvePath(t.CWD, a.Path)
52- if err := t.Sandbox.CheckPath(path); err != nil {
52+ if err := t.Sandbox.CheckReadPath(path); err != nil {
5353 return core.ToolResult{}, err
5454 }
5555 // What the model sees in errors: relative to the sandbox root when
@@ -49,7 +49,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
49 return core.ToolResult{}, fmt.Errorf("path is required")49 return core.ToolResult{}, fmt.Errorf("path is required")
50 }50 }
51 path := resolvePath(t.CWD, a.Path)51 path := resolvePath(t.CWD, a.Path)
52- if err := t.Sandbox.CheckPath(path); err != nil {52+ if err := t.Sandbox.CheckReadPath(path); err != nil {
53 return core.ToolResult{}, err53 return core.ToolResult{}, err
54 }54 }
55 // What the model sees in errors: relative to the sandbox root when55 // What the model sees in errors: relative to the sandbox root when
M packages/agent/tools/sandbox.go +3 -2
@@ -15,8 +15,9 @@ import (
1515 // The value is designed to be shared across tool instances (by pointer).
1616 // Enable/Disable are atomic so they can be toggled from the TUI.
1717 type Sandbox struct {
18- Root string
19- locked atomic.Bool
18+ Root string
19+ Permissions *PermissionSet
20+ locked atomic.Bool
2021 }
2122
2223 // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked.
@@ -15,8 +15,9 @@ import (
15 // The value is designed to be shared across tool instances (by pointer).15 // The value is designed to be shared across tool instances (by pointer).
16 // Enable/Disable are atomic so they can be toggled from the TUI.16 // Enable/Disable are atomic so they can be toggled from the TUI.
17 type Sandbox struct {17 type Sandbox struct {
18- Root string18+ Root string
19- locked atomic.Bool19+ Permissions *PermissionSet
20+ locked atomic.Bool
20 }21 }
21 22
22 // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked.23 // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked.
M packages/agent/tools/write.go +1 -1
@@ -40,7 +40,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
4040 return core.ToolResult{}, fmt.Errorf("path is required")
4141 }
4242 path := resolvePath(t.CWD, a.Path)
43- if err := t.Sandbox.CheckPath(path); err != nil {
43+ if err := t.Sandbox.CheckWritePath(path); err != nil {
4444 return core.ToolResult{}, err
4545 }
4646
@@ -40,7 +40,7 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
40 return core.ToolResult{}, fmt.Errorf("path is required")40 return core.ToolResult{}, fmt.Errorf("path is required")
41 }41 }
42 path := resolvePath(t.CWD, a.Path)42 path := resolvePath(t.CWD, a.Path)
43- if err := t.Sandbox.CheckPath(path); err != nil {43+ if err := t.Sandbox.CheckWritePath(path); err != nil {
44 return core.ToolResult{}, err44 return core.ToolResult{}, err
45 }45 }
46 46
A packages/agent/zotfile.go +684 -0
@@ -0,0 +1,684 @@
1+package agent
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "compress/gzip"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "encoding/json"
10+ "fmt"
11+ "io"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "runtime"
16+ "sort"
17+ "strings"
18+ "time"
19+
20+ "github.com/klauspost/compress/zstd"
21+ "github.com/patriceckhart/zot/packages/agent/tools"
22+ "github.com/patriceckhart/zot/packages/provider"
23+ "github.com/patriceckhart/zot/packages/provider/auth"
24+ "golang.org/x/term"
25+)
26+
27+type ZotfileManifest struct {
28+ Zotfile int `json:"zotfile"`
29+ Name string `json:"name"`
30+ Version string `json:"version"`
31+ Description string `json:"description"`
32+ License string `json:"license"`
33+ Runtime struct {
34+ MinZot string `json:"min_zot"`
35+ } `json:"runtime"`
36+ Model struct {
37+ Requires []string `json:"requires"`
38+ MinContext int `json:"min_context"`
39+ Preferred []string `json:"preferred"`
40+ MinTier string `json:"min_tier"`
41+ } `json:"model"`
42+ Permissions tools.PermissionSet `json:"permissions"`
43+ Requirements struct {
44+ Bin []string `json:"bin"`
45+ OS []string `json:"os"`
46+ } `json:"requirements"`
47+ Entry struct {
48+ Greeting string `json:"greeting"`
49+ DefaultPrompt *string `json:"default_prompt"`
50+ } `json:"entry"`
51+ ReplaceSystemPrompt bool `json:"replace_system_prompt"`
52+}
53+
54+type zotfileLoaded struct {
55+ Dir string
56+ Temp bool
57+ Digest string
58+ Manifest ZotfileManifest
59+}
60+
61+func runZotfileCommand(rawArgs []string, version string) (bool, error) {
62+ if len(rawArgs) == 0 {
63+ return false, nil
64+ }
65+ switch rawArgs[0] {
66+ case "pack":
67+ dir := "."
68+ out := ""
69+ if len(rawArgs) > 1 {
70+ dir = rawArgs[1]
71+ }
72+ if len(rawArgs) > 2 {
73+ out = rawArgs[2]
74+ }
75+ return true, zotPack(dir, out)
76+ case "inspect":
77+ if len(rawArgs) < 2 {
78+ return true, fmt.Errorf("zot inspect requires a .zot file or directory")
79+ }
80+ return true, zotInspect(rawArgs[1])
81+ case "verify":
82+ if len(rawArgs) < 2 {
83+ return true, fmt.Errorf("zot verify requires a .zot file or directory")
84+ }
85+ zf, cleanup, err := loadZotfile(rawArgs[1])
86+ if cleanup != nil {
87+ defer cleanup()
88+ }
89+ if err != nil {
90+ return true, err
91+ }
92+ fmt.Printf("ok digest sha256:%s\n", zf.Digest)
93+ return true, nil
94+ case "run":
95+ if len(rawArgs) < 2 {
96+ return true, fmt.Errorf("zot run requires a .zot file or directory")
97+ }
98+ ref := rawArgs[1]
99+ rest := rawArgs[2:]
100+ args, err := ParseArgs(rest)
101+ if err != nil {
102+ PrintHelp(version)
103+ return true, err
104+ }
105+ return true, runLocalZotfile(ref, args, version)
106+ default:
107+ return false, nil
108+ }
109+}
110+
111+func runLocalZotfile(ref string, args Args, version string) error {
112+ prepareRuntimeCatalog()
113+ zf, cleanup, err := loadZotfile(ref)
114+ if cleanup != nil {
115+ defer cleanup()
116+ }
117+ if err != nil {
118+ return err
119+ }
120+ if err := checkZotfileRequirements(zf, version); err != nil {
121+ return err
122+ }
123+ agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
124+ if err := os.MkdirAll(agentData, 0o755); err != nil {
125+ return err
126+ }
127+ perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
128+ if err := consentZotfile(zf, perms); err != nil {
129+ return err
130+ }
131+ agentPath := filepath.Join(zf.Dir, "AGENT.md")
132+ agentPrompt, err := os.ReadFile(agentPath)
133+ if err != nil {
134+ return fmt.Errorf("read AGENT.md: %w", err)
135+ }
136+ if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil {
137+ return err
138+ }
139+ if zf.Manifest.ReplaceSystemPrompt {
140+ args.SystemPrompt = strings.TrimSpace(string(agentPrompt))
141+ } else {
142+ args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt)))
143+ }
144+ if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
145+ args.Prompt = *zf.Manifest.Entry.DefaultPrompt
146+ }
147+ if dirExists(filepath.Join(zf.Dir, "skills")) {
148+ old := os.Getenv("ZOT_AGENT_SKILLS")
149+ v := filepath.Join(zf.Dir, "skills")
150+ if old != "" {
151+ v += string(os.PathListSeparator) + old
152+ }
153+ _ = os.Setenv("ZOT_AGENT_SKILLS", v)
154+ defer os.Setenv("ZOT_AGENT_SKILLS", old)
155+ }
156+ args.AgentName = zf.Manifest.Name
157+ args.AgentDataDir = agentData
158+ args.PermissionSet = &perms
159+ return runWithArgs(args, version)
160+}
161+
162+func zotInspect(ref string) error {
163+ zf, cleanup, err := loadZotfile(ref)
164+ if cleanup != nil {
165+ defer cleanup()
166+ }
167+ if err != nil {
168+ return err
169+ }
170+ m := zf.Manifest
171+ fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
172+ fmt.Println("\npermissions:")
173+ fmt.Print(permissionSummary(m.Permissions))
174+ fmt.Println("\nfiles:")
175+ return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
176+ if err != nil || path == zf.Dir {
177+ return err
178+ }
179+ rel, _ := filepath.Rel(zf.Dir, path)
180+ if d.IsDir() {
181+ rel += "/"
182+ }
183+ fmt.Println(" " + filepath.ToSlash(rel))
184+ return nil
185+ })
186+}
187+
188+func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
189+ minCtx := m.Model.MinContext
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")
201+ }
202+ compatible := func(model provider.Model) bool {
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
215+ }
216+ if args.Model != "" {
217+ model, err := provider.FindModel(args.Provider, args.Model)
218+ if err != nil {
219+ model, err = provider.FindModel("", args.Model)
220+ }
221+ if err != nil {
222+ return nil
223+ }
224+ if !compatible(model) {
225+ return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
226+ }
227+ return nil
228+ }
229+ cfg, _ := LoadConfig()
230+ if cfg.Model != "" {
231+ model, err := provider.FindModel(cfg.Provider, cfg.Model)
232+ if err == nil && compatible(model) {
233+ return nil
234+ }
235+ }
236+ for _, id := range m.Model.Preferred {
237+ model, err := provider.FindModel("", id)
238+ if err == nil && compatible(model) {
239+ args.Provider = model.Provider
240+ args.Model = model.ID
241+ return nil
242+ }
243+ }
244+ for _, model := range provider.Active() {
245+ if compatible(model) {
246+ args.Provider = model.Provider
247+ args.Model = model.ID
248+ return nil
249+ }
250+ }
251+ return fmt.Errorf("no catalog model satisfies the agent requirements")
252+}
253+
254+func prepareRuntimeCatalog() {
255+ LoadCachedModels()
256+ LoadUserModels()
257+ if cps := provider.CustomProviders(); len(cps) > 0 {
258+ var names []string
259+ for name := range cps {
260+ if !isBuiltinProvider(name) {
261+ names = append(names, name)
262+ }
263+ }
264+ auth.SetExtraAPIKeyProviders(names)
265+ }
266+ ValidateAndRepairConfig()
267+ RefreshModelsAsync()
268+}
269+
270+func zotPack(dir, out string) error {
271+ abs, err := filepath.Abs(dir)
272+ if err != nil {
273+ return err
274+ }
275+ if _, err := readZotManifest(abs); err != nil {
276+ return err
277+ }
278+ if err := validateZotfileDir(abs); err != nil {
279+ return err
280+ }
281+ if out == "" {
282+ m, _ := readZotManifest(abs)
283+ base := safeAgentName(m.Name)
284+ if base == "" {
285+ base = filepath.Base(abs)
286+ }
287+ out = base + ".zot"
288+ }
289+ if filepath.Ext(out) != ".zot" {
290+ out += ".zot"
291+ }
292+ outAbs, err := filepath.Abs(out)
293+ if err != nil {
294+ return err
295+ }
296+ f, err := os.Create(out)
297+ if err != nil {
298+ return err
299+ }
300+ defer f.Close()
301+ h := sha256.New()
302+ mw := io.MultiWriter(f, h)
303+ enc, err := zstd.NewWriter(mw)
304+ if err != nil {
305+ return err
306+ }
307+ if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
308+ enc.Close()
309+ return err
310+ }
311+ if err := enc.Close(); err != nil {
312+ return err
313+ }
314+ fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
315+ return nil
316+}
317+
318+func loadZotfile(ref string) (zotfileLoaded, func(), error) {
319+ info, err := os.Stat(ref)
320+ if err != nil {
321+ return zotfileLoaded{}, nil, err
322+ }
323+ if info.IsDir() {
324+ abs, _ := filepath.Abs(ref)
325+ m, err := readZotManifest(abs)
326+ if err != nil {
327+ return zotfileLoaded{}, nil, err
328+ }
329+ if err := validateZotfileDir(abs); err != nil {
330+ return zotfileLoaded{}, nil, err
331+ }
332+ return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
333+ }
334+ tmp, err := os.MkdirTemp("", "zotfile-*")
335+ if err != nil {
336+ return zotfileLoaded{}, nil, err
337+ }
338+ cleanup := func() { _ = os.RemoveAll(tmp) }
339+ digest, err := unpackZotArchive(ref, tmp)
340+ if err != nil {
341+ cleanup()
342+ return zotfileLoaded{}, nil, err
343+ }
344+ m, err := readZotManifest(tmp)
345+ if err != nil {
346+ cleanup()
347+ return zotfileLoaded{}, nil, err
348+ }
349+ if err := validateZotfileDir(tmp); err != nil {
350+ cleanup()
351+ return zotfileLoaded{}, nil, err
352+ }
353+ return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354+}
355+
356+func readZotManifest(dir string) (ZotfileManifest, error) {
357+ var m ZotfileManifest
358+ b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
359+ if err != nil {
360+ return m, fmt.Errorf("manifest.json is required: %w", err)
361+ }
362+ if err := json.Unmarshal(b, &m); err != nil {
363+ return m, fmt.Errorf("manifest.json: %w", err)
364+ }
365+ if m.Zotfile != 1 {
366+ return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
367+ }
368+ name := strings.TrimSpace(m.Name)
369+ if name == "" {
370+ return m, fmt.Errorf("manifest name is required")
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+ }
388+ return m, nil
389+}
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+
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+ }
416+ b, err := os.ReadFile(path)
417+ if err != nil {
418+ return "", err
419+ }
420+ digest := sha256.Sum256(b)
421+ r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize))
422+ if err != nil {
423+ // Development fallback for older experiments.
424+ gr, gerr := gzip.NewReader(bytes.NewReader(b))
425+ if gerr != nil {
426+ return "", err
427+ }
428+ defer gr.Close()
429+ return hex.EncodeToString(digest[:]), untar(gr, dst)
430+ }
431+ defer r.Close()
432+ return hex.EncodeToString(digest[:]), untar(r, dst)
433+}
434+
435+func untar(r io.Reader, dst string) error {
436+ tr := tar.NewReader(r)
437+ var expanded int64
438+ for {
439+ h, err := tr.Next()
440+ if err == io.EOF {
441+ return nil
442+ }
443+ if err != nil {
444+ return err
445+ }
446+ name := filepath.Clean(h.Name)
447+ if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
448+ return fmt.Errorf("unsafe path in archive: %s", h.Name)
449+ }
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
455+ switch h.Typeflag {
456+ case tar.TypeDir:
457+ if err := os.MkdirAll(path, 0o755); err != nil {
458+ return err
459+ }
460+ case tar.TypeReg:
461+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
462+ return err
463+ }
464+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
465+ if err != nil {
466+ return err
467+ }
468+ _, cerr := io.Copy(f, tr)
469+ if err := f.Close(); err != nil && cerr == nil {
470+ cerr = err
471+ }
472+ if cerr != nil {
473+ return cerr
474+ }
475+ }
476+ }
477+}
478+
479+func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
480+ excluded := map[string]bool{}
481+ for _, p := range exclude {
482+ if p == "" {
483+ continue
484+ }
485+ abs, err := filepath.Abs(p)
486+ if err == nil {
487+ excluded[abs] = true
488+ }
489+ }
490+ var files []string
491+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
492+ if err != nil || path == root {
493+ return err
494+ }
495+ absPath, _ := filepath.Abs(path)
496+ if excluded[absPath] {
497+ return nil
498+ }
499+ if d.Type()&os.ModeSymlink != 0 {
500+ return fmt.Errorf("symlinks are not supported in zotfiles: %s", path)
501+ }
502+ files = append(files, path)
503+ return nil
504+ }); err != nil {
505+ return err
506+ }
507+ sort.Strings(files)
508+ tw := tar.NewWriter(w)
509+ defer tw.Close()
510+ fixed := time.Unix(0, 0)
511+ for _, path := range files {
512+ info, err := os.Stat(path)
513+ if err != nil {
514+ return err
515+ }
516+ rel, _ := filepath.Rel(root, path)
517+ rel = filepath.ToSlash(rel)
518+ h, err := tar.FileInfoHeader(info, "")
519+ if err != nil {
520+ return err
521+ }
522+ h.Name = rel
523+ h.ModTime = fixed
524+ h.AccessTime = fixed
525+ h.ChangeTime = fixed
526+ h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
527+ if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
528+ h.Name += "/"
529+ }
530+ if err := tw.WriteHeader(h); err != nil {
531+ return err
532+ }
533+ if info.Mode().IsRegular() {
534+ f, err := os.Open(path)
535+ if err != nil {
536+ return err
537+ }
538+ _, cerr := io.Copy(tw, f)
539+ _ = f.Close()
540+ if cerr != nil {
541+ return cerr
542+ }
543+ }
544+ }
545+ return nil
546+}
547+
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+ }
557+ if len(zf.Manifest.Requirements.OS) > 0 {
558+ ok := false
559+ for _, osName := range zf.Manifest.Requirements.OS {
560+ if osName == runtime.GOOS {
561+ ok = true
562+ break
563+ }
564+ }
565+ if !ok {
566+ return fmt.Errorf("agent does not support %s", runtime.GOOS)
567+ }
568+ }
569+ if len(zf.Manifest.Requirements.Bin) > 0 {
570+ for _, b := range zf.Manifest.Requirements.Bin {
571+ if _, err := execLookPath(b); err != nil {
572+ return fmt.Errorf("agent requires binary %q", b)
573+ }
574+ }
575+ }
576+ return nil
577+}
578+
579+var execLookPath = exec.LookPath
580+
581+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
582+ if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
583+ return nil
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+ }
594+ fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
595+ fmt.Print(permissionSummary(perms))
596+ if !term.IsTerminal(int(os.Stdin.Fd())) {
597+ return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
598+ }
599+ fmt.Print("\nAllow? [y/N] ")
600+ var answer string
601+ _, _ = fmt.Fscanln(os.Stdin, &answer)
602+ answer = strings.ToLower(strings.TrimSpace(answer))
603+ if answer != "y" && answer != "yes" {
604+ return fmt.Errorf("declined")
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+ }
617+ return nil
618+}
619+
620+func permissionSummary(p tools.PermissionSet) string {
621+ var sb strings.Builder
622+ if len(p.FS.Read) > 0 {
623+ fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))
624+ } else {
625+ fmt.Fprintln(&sb, " fs read: none")
626+ }
627+ if len(p.FS.Write) > 0 {
628+ fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))
629+ } else {
630+ fmt.Fprintln(&sb, " fs write: none")
631+ }
632+ mode := p.Bash.Mode
633+ if mode == "" {
634+ mode = "none"
635+ }
636+ fmt.Fprintf(&sb, " bash: %s", mode)
637+ if len(p.Bash.Allow) > 0 {
638+ fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", "))
639+ }
640+ fmt.Fprintln(&sb)
641+ if len(p.Net.Allow) > 0 {
642+ fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
643+ }
644+ if len(p.Env.Read) > 0 {
645+ fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", "))
646+ }
647+ return sb.String()
648+}
649+
650+func bundledExtensionDirs(root string) []string {
651+ entries, err := os.ReadDir(root)
652+ if err != nil {
653+ return nil
654+ }
655+ var out []string
656+ for _, e := range entries {
657+ if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
658+ out = append(out, filepath.Join(root, e.Name()))
659+ }
660+ }
661+ return out
662+}
663+
664+func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
665+func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
666+
667+func safeAgentName(s string) string {
668+ s = strings.TrimSpace(strings.ToLower(s))
669+ s = strings.ReplaceAll(s, "/", "_")
670+ s = strings.ReplaceAll(s, "\\", "_")
671+ s = strings.Map(func(r rune) rune {
672+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
673+ return r
674+ }
675+ return '-'
676+ }, s)
677+ return strings.Trim(s, "-._")
678+}
679+
680+func digestDirectory(dir string) string {
681+ h := sha256.New()
682+ _ = writeCanonicalTar(dir, h)
683+ return hex.EncodeToString(h.Sum(nil))
684+}
@@ -0,0 +1,684 @@
1+package agent
2+
3+import (
4+ "archive/tar"
5+ "bytes"
6+ "compress/gzip"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "encoding/json"
10+ "fmt"
11+ "io"
12+ "os"
13+ "os/exec"
14+ "path/filepath"
15+ "runtime"
16+ "sort"
17+ "strings"
18+ "time"
19+
20+ "github.com/klauspost/compress/zstd"
21+ "github.com/patriceckhart/zot/packages/agent/tools"
22+ "github.com/patriceckhart/zot/packages/provider"
23+ "github.com/patriceckhart/zot/packages/provider/auth"
24+ "golang.org/x/term"
25+)
26+
27+type ZotfileManifest struct {
28+ Zotfile int `json:"zotfile"`
29+ Name string `json:"name"`
30+ Version string `json:"version"`
31+ Description string `json:"description"`
32+ License string `json:"license"`
33+ Runtime struct {
34+ MinZot string `json:"min_zot"`
35+ } `json:"runtime"`
36+ Model struct {
37+ Requires []string `json:"requires"`
38+ MinContext int `json:"min_context"`
39+ Preferred []string `json:"preferred"`
40+ MinTier string `json:"min_tier"`
41+ } `json:"model"`
42+ Permissions tools.PermissionSet `json:"permissions"`
43+ Requirements struct {
44+ Bin []string `json:"bin"`
45+ OS []string `json:"os"`
46+ } `json:"requirements"`
47+ Entry struct {
48+ Greeting string `json:"greeting"`
49+ DefaultPrompt *string `json:"default_prompt"`
50+ } `json:"entry"`
51+ ReplaceSystemPrompt bool `json:"replace_system_prompt"`
52+}
53+
54+type zotfileLoaded struct {
55+ Dir string
56+ Temp bool
57+ Digest string
58+ Manifest ZotfileManifest
59+}
60+
61+func runZotfileCommand(rawArgs []string, version string) (bool, error) {
62+ if len(rawArgs) == 0 {
63+ return false, nil
64+ }
65+ switch rawArgs[0] {
66+ case "pack":
67+ dir := "."
68+ out := ""
69+ if len(rawArgs) > 1 {
70+ dir = rawArgs[1]
71+ }
72+ if len(rawArgs) > 2 {
73+ out = rawArgs[2]
74+ }
75+ return true, zotPack(dir, out)
76+ case "inspect":
77+ if len(rawArgs) < 2 {
78+ return true, fmt.Errorf("zot inspect requires a .zot file or directory")
79+ }
80+ return true, zotInspect(rawArgs[1])
81+ case "verify":
82+ if len(rawArgs) < 2 {
83+ return true, fmt.Errorf("zot verify requires a .zot file or directory")
84+ }
85+ zf, cleanup, err := loadZotfile(rawArgs[1])
86+ if cleanup != nil {
87+ defer cleanup()
88+ }
89+ if err != nil {
90+ return true, err
91+ }
92+ fmt.Printf("ok digest sha256:%s\n", zf.Digest)
93+ return true, nil
94+ case "run":
95+ if len(rawArgs) < 2 {
96+ return true, fmt.Errorf("zot run requires a .zot file or directory")
97+ }
98+ ref := rawArgs[1]
99+ rest := rawArgs[2:]
100+ args, err := ParseArgs(rest)
101+ if err != nil {
102+ PrintHelp(version)
103+ return true, err
104+ }
105+ return true, runLocalZotfile(ref, args, version)
106+ default:
107+ return false, nil
108+ }
109+}
110+
111+func runLocalZotfile(ref string, args Args, version string) error {
112+ prepareRuntimeCatalog()
113+ zf, cleanup, err := loadZotfile(ref)
114+ if cleanup != nil {
115+ defer cleanup()
116+ }
117+ if err != nil {
118+ return err
119+ }
120+ if err := checkZotfileRequirements(zf, version); err != nil {
121+ return err
122+ }
123+ agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
124+ if err := os.MkdirAll(agentData, 0o755); err != nil {
125+ return err
126+ }
127+ perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
128+ if err := consentZotfile(zf, perms); err != nil {
129+ return err
130+ }
131+ agentPath := filepath.Join(zf.Dir, "AGENT.md")
132+ agentPrompt, err := os.ReadFile(agentPath)
133+ if err != nil {
134+ return fmt.Errorf("read AGENT.md: %w", err)
135+ }
136+ if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil {
137+ return err
138+ }
139+ if zf.Manifest.ReplaceSystemPrompt {
140+ args.SystemPrompt = strings.TrimSpace(string(agentPrompt))
141+ } else {
142+ args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt)))
143+ }
144+ if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
145+ args.Prompt = *zf.Manifest.Entry.DefaultPrompt
146+ }
147+ if dirExists(filepath.Join(zf.Dir, "skills")) {
148+ old := os.Getenv("ZOT_AGENT_SKILLS")
149+ v := filepath.Join(zf.Dir, "skills")
150+ if old != "" {
151+ v += string(os.PathListSeparator) + old
152+ }
153+ _ = os.Setenv("ZOT_AGENT_SKILLS", v)
154+ defer os.Setenv("ZOT_AGENT_SKILLS", old)
155+ }
156+ args.AgentName = zf.Manifest.Name
157+ args.AgentDataDir = agentData
158+ args.PermissionSet = &perms
159+ return runWithArgs(args, version)
160+}
161+
162+func zotInspect(ref string) error {
163+ zf, cleanup, err := loadZotfile(ref)
164+ if cleanup != nil {
165+ defer cleanup()
166+ }
167+ if err != nil {
168+ return err
169+ }
170+ m := zf.Manifest
171+ fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
172+ fmt.Println("\npermissions:")
173+ fmt.Print(permissionSummary(m.Permissions))
174+ fmt.Println("\nfiles:")
175+ return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
176+ if err != nil || path == zf.Dir {
177+ return err
178+ }
179+ rel, _ := filepath.Rel(zf.Dir, path)
180+ if d.IsDir() {
181+ rel += "/"
182+ }
183+ fmt.Println(" " + filepath.ToSlash(rel))
184+ return nil
185+ })
186+}
187+
188+func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
189+ minCtx := m.Model.MinContext
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")
201+ }
202+ compatible := func(model provider.Model) bool {
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
215+ }
216+ if args.Model != "" {
217+ model, err := provider.FindModel(args.Provider, args.Model)
218+ if err != nil {
219+ model, err = provider.FindModel("", args.Model)
220+ }
221+ if err != nil {
222+ return nil
223+ }
224+ if !compatible(model) {
225+ return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
226+ }
227+ return nil
228+ }
229+ cfg, _ := LoadConfig()
230+ if cfg.Model != "" {
231+ model, err := provider.FindModel(cfg.Provider, cfg.Model)
232+ if err == nil && compatible(model) {
233+ return nil
234+ }
235+ }
236+ for _, id := range m.Model.Preferred {
237+ model, err := provider.FindModel("", id)
238+ if err == nil && compatible(model) {
239+ args.Provider = model.Provider
240+ args.Model = model.ID
241+ return nil
242+ }
243+ }
244+ for _, model := range provider.Active() {
245+ if compatible(model) {
246+ args.Provider = model.Provider
247+ args.Model = model.ID
248+ return nil
249+ }
250+ }
251+ return fmt.Errorf("no catalog model satisfies the agent requirements")
252+}
253+
254+func prepareRuntimeCatalog() {
255+ LoadCachedModels()
256+ LoadUserModels()
257+ if cps := provider.CustomProviders(); len(cps) > 0 {
258+ var names []string
259+ for name := range cps {
260+ if !isBuiltinProvider(name) {
261+ names = append(names, name)
262+ }
263+ }
264+ auth.SetExtraAPIKeyProviders(names)
265+ }
266+ ValidateAndRepairConfig()
267+ RefreshModelsAsync()
268+}
269+
270+func zotPack(dir, out string) error {
271+ abs, err := filepath.Abs(dir)
272+ if err != nil {
273+ return err
274+ }
275+ if _, err := readZotManifest(abs); err != nil {
276+ return err
277+ }
278+ if err := validateZotfileDir(abs); err != nil {
279+ return err
280+ }
281+ if out == "" {
282+ m, _ := readZotManifest(abs)
283+ base := safeAgentName(m.Name)
284+ if base == "" {
285+ base = filepath.Base(abs)
286+ }
287+ out = base + ".zot"
288+ }
289+ if filepath.Ext(out) != ".zot" {
290+ out += ".zot"
291+ }
292+ outAbs, err := filepath.Abs(out)
293+ if err != nil {
294+ return err
295+ }
296+ f, err := os.Create(out)
297+ if err != nil {
298+ return err
299+ }
300+ defer f.Close()
301+ h := sha256.New()
302+ mw := io.MultiWriter(f, h)
303+ enc, err := zstd.NewWriter(mw)
304+ if err != nil {
305+ return err
306+ }
307+ if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
308+ enc.Close()
309+ return err
310+ }
311+ if err := enc.Close(); err != nil {
312+ return err
313+ }
314+ fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
315+ return nil
316+}
317+
318+func loadZotfile(ref string) (zotfileLoaded, func(), error) {
319+ info, err := os.Stat(ref)
320+ if err != nil {
321+ return zotfileLoaded{}, nil, err
322+ }
323+ if info.IsDir() {
324+ abs, _ := filepath.Abs(ref)
325+ m, err := readZotManifest(abs)
326+ if err != nil {
327+ return zotfileLoaded{}, nil, err
328+ }
329+ if err := validateZotfileDir(abs); err != nil {
330+ return zotfileLoaded{}, nil, err
331+ }
332+ return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
333+ }
334+ tmp, err := os.MkdirTemp("", "zotfile-*")
335+ if err != nil {
336+ return zotfileLoaded{}, nil, err
337+ }
338+ cleanup := func() { _ = os.RemoveAll(tmp) }
339+ digest, err := unpackZotArchive(ref, tmp)
340+ if err != nil {
341+ cleanup()
342+ return zotfileLoaded{}, nil, err
343+ }
344+ m, err := readZotManifest(tmp)
345+ if err != nil {
346+ cleanup()
347+ return zotfileLoaded{}, nil, err
348+ }
349+ if err := validateZotfileDir(tmp); err != nil {
350+ cleanup()
351+ return zotfileLoaded{}, nil, err
352+ }
353+ return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354+}
355+
356+func readZotManifest(dir string) (ZotfileManifest, error) {
357+ var m ZotfileManifest
358+ b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
359+ if err != nil {
360+ return m, fmt.Errorf("manifest.json is required: %w", err)
361+ }
362+ if err := json.Unmarshal(b, &m); err != nil {
363+ return m, fmt.Errorf("manifest.json: %w", err)
364+ }
365+ if m.Zotfile != 1 {
366+ return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
367+ }
368+ name := strings.TrimSpace(m.Name)
369+ if name == "" {
370+ return m, fmt.Errorf("manifest name is required")
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+ }
388+ return m, nil
389+}
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+
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+ }
416+ b, err := os.ReadFile(path)
417+ if err != nil {
418+ return "", err
419+ }
420+ digest := sha256.Sum256(b)
421+ r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize))
422+ if err != nil {
423+ // Development fallback for older experiments.
424+ gr, gerr := gzip.NewReader(bytes.NewReader(b))
425+ if gerr != nil {
426+ return "", err
427+ }
428+ defer gr.Close()
429+ return hex.EncodeToString(digest[:]), untar(gr, dst)
430+ }
431+ defer r.Close()
432+ return hex.EncodeToString(digest[:]), untar(r, dst)
433+}
434+
435+func untar(r io.Reader, dst string) error {
436+ tr := tar.NewReader(r)
437+ var expanded int64
438+ for {
439+ h, err := tr.Next()
440+ if err == io.EOF {
441+ return nil
442+ }
443+ if err != nil {
444+ return err
445+ }
446+ name := filepath.Clean(h.Name)
447+ if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
448+ return fmt.Errorf("unsafe path in archive: %s", h.Name)
449+ }
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
455+ switch h.Typeflag {
456+ case tar.TypeDir:
457+ if err := os.MkdirAll(path, 0o755); err != nil {
458+ return err
459+ }
460+ case tar.TypeReg:
461+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
462+ return err
463+ }
464+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
465+ if err != nil {
466+ return err
467+ }
468+ _, cerr := io.Copy(f, tr)
469+ if err := f.Close(); err != nil && cerr == nil {
470+ cerr = err
471+ }
472+ if cerr != nil {
473+ return cerr
474+ }
475+ }
476+ }
477+}
478+
479+func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
480+ excluded := map[string]bool{}
481+ for _, p := range exclude {
482+ if p == "" {
483+ continue
484+ }
485+ abs, err := filepath.Abs(p)
486+ if err == nil {
487+ excluded[abs] = true
488+ }
489+ }
490+ var files []string
491+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
492+ if err != nil || path == root {
493+ return err
494+ }
495+ absPath, _ := filepath.Abs(path)
496+ if excluded[absPath] {
497+ return nil
498+ }
499+ if d.Type()&os.ModeSymlink != 0 {
500+ return fmt.Errorf("symlinks are not supported in zotfiles: %s", path)
501+ }
502+ files = append(files, path)
503+ return nil
504+ }); err != nil {
505+ return err
506+ }
507+ sort.Strings(files)
508+ tw := tar.NewWriter(w)
509+ defer tw.Close()
510+ fixed := time.Unix(0, 0)
511+ for _, path := range files {
512+ info, err := os.Stat(path)
513+ if err != nil {
514+ return err
515+ }
516+ rel, _ := filepath.Rel(root, path)
517+ rel = filepath.ToSlash(rel)
518+ h, err := tar.FileInfoHeader(info, "")
519+ if err != nil {
520+ return err
521+ }
522+ h.Name = rel
523+ h.ModTime = fixed
524+ h.AccessTime = fixed
525+ h.ChangeTime = fixed
526+ h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
527+ if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
528+ h.Name += "/"
529+ }
530+ if err := tw.WriteHeader(h); err != nil {
531+ return err
532+ }
533+ if info.Mode().IsRegular() {
534+ f, err := os.Open(path)
535+ if err != nil {
536+ return err
537+ }
538+ _, cerr := io.Copy(tw, f)
539+ _ = f.Close()
540+ if cerr != nil {
541+ return cerr
542+ }
543+ }
544+ }
545+ return nil
546+}
547+
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+ }
557+ if len(zf.Manifest.Requirements.OS) > 0 {
558+ ok := false
559+ for _, osName := range zf.Manifest.Requirements.OS {
560+ if osName == runtime.GOOS {
561+ ok = true
562+ break
563+ }
564+ }
565+ if !ok {
566+ return fmt.Errorf("agent does not support %s", runtime.GOOS)
567+ }
568+ }
569+ if len(zf.Manifest.Requirements.Bin) > 0 {
570+ for _, b := range zf.Manifest.Requirements.Bin {
571+ if _, err := execLookPath(b); err != nil {
572+ return fmt.Errorf("agent requires binary %q", b)
573+ }
574+ }
575+ }
576+ return nil
577+}
578+
579+var execLookPath = exec.LookPath
580+
581+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
582+ if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
583+ return nil
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+ }
594+ fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
595+ fmt.Print(permissionSummary(perms))
596+ if !term.IsTerminal(int(os.Stdin.Fd())) {
597+ return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
598+ }
599+ fmt.Print("\nAllow? [y/N] ")
600+ var answer string
601+ _, _ = fmt.Fscanln(os.Stdin, &answer)
602+ answer = strings.ToLower(strings.TrimSpace(answer))
603+ if answer != "y" && answer != "yes" {
604+ return fmt.Errorf("declined")
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+ }
617+ return nil
618+}
619+
620+func permissionSummary(p tools.PermissionSet) string {
621+ var sb strings.Builder
622+ if len(p.FS.Read) > 0 {
623+ fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))
624+ } else {
625+ fmt.Fprintln(&sb, " fs read: none")
626+ }
627+ if len(p.FS.Write) > 0 {
628+ fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))
629+ } else {
630+ fmt.Fprintln(&sb, " fs write: none")
631+ }
632+ mode := p.Bash.Mode
633+ if mode == "" {
634+ mode = "none"
635+ }
636+ fmt.Fprintf(&sb, " bash: %s", mode)
637+ if len(p.Bash.Allow) > 0 {
638+ fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", "))
639+ }
640+ fmt.Fprintln(&sb)
641+ if len(p.Net.Allow) > 0 {
642+ fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
643+ }
644+ if len(p.Env.Read) > 0 {
645+ fmt.Fprintf(&sb, " env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", "))
646+ }
647+ return sb.String()
648+}
649+
650+func bundledExtensionDirs(root string) []string {
651+ entries, err := os.ReadDir(root)
652+ if err != nil {
653+ return nil
654+ }
655+ var out []string
656+ for _, e := range entries {
657+ if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
658+ out = append(out, filepath.Join(root, e.Name()))
659+ }
660+ }
661+ return out
662+}
663+
664+func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
665+func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
666+
667+func safeAgentName(s string) string {
668+ s = strings.TrimSpace(strings.ToLower(s))
669+ s = strings.ReplaceAll(s, "/", "_")
670+ s = strings.ReplaceAll(s, "\\", "_")
671+ s = strings.Map(func(r rune) rune {
672+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
673+ return r
674+ }
675+ return '-'
676+ }, s)
677+ return strings.Trim(s, "-._")
678+}
679+
680+func digestDirectory(dir string) string {
681+ h := sha256.New()
682+ _ = writeCanonicalTar(dir, h)
683+ return hex.EncodeToString(h.Sum(nil))
684+}
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 476

↑↓ moveenter openesc close