detached 8c6874a

Add local Zotfile runtime

commit
8c6874afde9ff4ac41b2088921bffabe1b4ad528
author
patriceckhart · · 2026-07-08 17:15 UTC
parent
88a6f6e
signature
unsigned
13 files · +798 -39
M go.mod +1 -0
@@ -14,6 +14,7 @@ require (
1414
1515 require (
1616 github.com/dlclark/regexp2 v1.11.5 // indirect
17+ github.com/klauspost/compress v1.19.0 // indirect
1718 github.com/kylelemons/godebug v1.1.0 // indirect
1819 github.com/rivo/uniseg v0.2.0 // indirect
1920 )
@@ -14,6 +14,7 @@ require (
14 14
15 require (15 require (
16 github.com/dlclark/regexp2 v1.11.5 // indirect16 github.com/dlclark/regexp2 v1.11.5 // indirect
17+ github.com/klauspost/compress v1.19.0 // indirect
17 github.com/kylelemons/godebug v1.1.0 // indirect18 github.com/kylelemons/godebug v1.1.0 // indirect
18 github.com/rivo/uniseg v0.2.0 // indirect19 github.com/rivo/uniseg v0.2.0 // indirect
19 )20 )
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())
@@ -874,6 +877,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
874877 if s == nil || r == nil {
875878 return
876879 }
880+ if r.Sandbox != nil && r.Sandbox.Permissions != nil {
881+ s.Permissions = r.Sandbox.Permissions
882+ }
877883 r.Sandbox = s
878884 for name, t := range r.ToolRegistry {
879885 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())
@@ -874,6 +877,9 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
874 if s == nil || r == nil {877 if s == nil || r == nil {
875 return878 return
876 }879 }
880+ if r.Sandbox != nil && r.Sandbox.Permissions != nil {
881+ s.Permissions = r.Sandbox.Permissions
882+ }
877 r.Sandbox = s883 r.Sandbox = s
878 for name, t := range r.ToolRegistry {884 for name, t := range r.ToolRegistry {
879 switch v := t.(type) {885 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 +152 -0
@@ -0,0 +1,152 @@
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+ for i, v := range p.FS.Read {
38+ p.FS.Read[i] = expand(v)
39+ }
40+ for i, v := range p.FS.Write {
41+ p.FS.Write[i] = expand(v)
42+ }
43+ return p
44+}
45+
46+func (s *Sandbox) SetPermissions(p *PermissionSet) {
47+ if s == nil {
48+ return
49+ }
50+ s.Permissions = p
51+}
52+
53+func (s *Sandbox) CheckReadPath(path string) error {
54+ if err := s.CheckPath(path); err != nil {
55+ return err
56+ }
57+ if s == nil || s.Permissions == nil || len(s.Permissions.FS.Read) == 0 {
58+ return nil
59+ }
60+ return checkScopedPath("read", path, s.Permissions.FS.Read)
61+}
62+
63+func (s *Sandbox) CheckWritePath(path string) error {
64+ if err := s.CheckPath(path); err != nil {
65+ return err
66+ }
67+ if s == nil || s.Permissions == nil || len(s.Permissions.FS.Write) == 0 {
68+ return nil
69+ }
70+ return checkScopedPath("write", path, s.Permissions.FS.Write)
71+}
72+
73+func checkScopedPath(op, path string, scopes []string) error {
74+ target, err := canonicalOrParent(path)
75+ if err != nil {
76+ return fmt.Errorf("permission path: %w", err)
77+ }
78+ for _, scope := range scopes {
79+ root, err := canonicalOrParent(scope)
80+ if err != nil {
81+ continue
82+ }
83+ if isUnder(root, target) {
84+ return nil
85+ }
86+ }
87+ return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)
88+}
89+
90+func (s *Sandbox) CheckBashPermission(cmd string) error {
91+ if s == nil || s.Permissions == nil {
92+ return nil
93+ }
94+ mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode))
95+ if mode == "" {
96+ mode = "none"
97+ }
98+ switch mode {
99+ case "none":
100+ return fmt.Errorf("permission denied: this agent has no bash permission")
101+ case "ask":
102+ return nil
103+ case "allowlist":
104+ names := commandNames(cmd)
105+ if len(names) == 0 {
106+ return fmt.Errorf("permission denied: empty command")
107+ }
108+ allowed := map[string]bool{}
109+ for _, name := range s.Permissions.Bash.Allow {
110+ allowed[name] = true
111+ }
112+ for _, name := range names {
113+ if !allowed[name] {
114+ return fmt.Errorf("permission denied: bash command %q is not in allowlist", name)
115+ }
116+ }
117+ return nil
118+ default:
119+ return fmt.Errorf("permission denied: unsupported bash mode %q", mode)
120+ }
121+}
122+
123+func commandNames(cmd string) []string {
124+ cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
125+ parts := strings.Split(cmd, ";")
126+ var out []string
127+ for _, part := range parts {
128+ name := firstCommandName(part)
129+ if name == "" || name == "cd" {
130+ continue
131+ }
132+ out = append(out, name)
133+ }
134+ return out
135+}
136+
137+func firstCommandName(cmd string) string {
138+ cmd = strings.TrimSpace(cmd)
139+ if cmd == "" {
140+ return ""
141+ }
142+ fields := strings.Fields(cmd)
143+ if len(fields) == 0 {
144+ return ""
145+ }
146+ name := fields[0]
147+ for strings.Contains(name, "=") && len(fields) > 1 {
148+ fields = fields[1:]
149+ name = fields[0]
150+ }
151+ return filepath.Base(strings.Trim(name, "'\""))
152+}
@@ -0,0 +1,152 @@
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+ for i, v := range p.FS.Read {
38+ p.FS.Read[i] = expand(v)
39+ }
40+ for i, v := range p.FS.Write {
41+ p.FS.Write[i] = expand(v)
42+ }
43+ return p
44+}
45+
46+func (s *Sandbox) SetPermissions(p *PermissionSet) {
47+ if s == nil {
48+ return
49+ }
50+ s.Permissions = p
51+}
52+
53+func (s *Sandbox) CheckReadPath(path string) error {
54+ if err := s.CheckPath(path); err != nil {
55+ return err
56+ }
57+ if s == nil || s.Permissions == nil || len(s.Permissions.FS.Read) == 0 {
58+ return nil
59+ }
60+ return checkScopedPath("read", path, s.Permissions.FS.Read)
61+}
62+
63+func (s *Sandbox) CheckWritePath(path string) error {
64+ if err := s.CheckPath(path); err != nil {
65+ return err
66+ }
67+ if s == nil || s.Permissions == nil || len(s.Permissions.FS.Write) == 0 {
68+ return nil
69+ }
70+ return checkScopedPath("write", path, s.Permissions.FS.Write)
71+}
72+
73+func checkScopedPath(op, path string, scopes []string) error {
74+ target, err := canonicalOrParent(path)
75+ if err != nil {
76+ return fmt.Errorf("permission path: %w", err)
77+ }
78+ for _, scope := range scopes {
79+ root, err := canonicalOrParent(scope)
80+ if err != nil {
81+ continue
82+ }
83+ if isUnder(root, target) {
84+ return nil
85+ }
86+ }
87+ return fmt.Errorf("permission denied: %s %q is outside declared scopes", op, path)
88+}
89+
90+func (s *Sandbox) CheckBashPermission(cmd string) error {
91+ if s == nil || s.Permissions == nil {
92+ return nil
93+ }
94+ mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode))
95+ if mode == "" {
96+ mode = "none"
97+ }
98+ switch mode {
99+ case "none":
100+ return fmt.Errorf("permission denied: this agent has no bash permission")
101+ case "ask":
102+ return nil
103+ case "allowlist":
104+ names := commandNames(cmd)
105+ if len(names) == 0 {
106+ return fmt.Errorf("permission denied: empty command")
107+ }
108+ allowed := map[string]bool{}
109+ for _, name := range s.Permissions.Bash.Allow {
110+ allowed[name] = true
111+ }
112+ for _, name := range names {
113+ if !allowed[name] {
114+ return fmt.Errorf("permission denied: bash command %q is not in allowlist", name)
115+ }
116+ }
117+ return nil
118+ default:
119+ return fmt.Errorf("permission denied: unsupported bash mode %q", mode)
120+ }
121+}
122+
123+func commandNames(cmd string) []string {
124+ cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
125+ parts := strings.Split(cmd, ";")
126+ var out []string
127+ for _, part := range parts {
128+ name := firstCommandName(part)
129+ if name == "" || name == "cd" {
130+ continue
131+ }
132+ out = append(out, name)
133+ }
134+ return out
135+}
136+
137+func firstCommandName(cmd string) string {
138+ cmd = strings.TrimSpace(cmd)
139+ if cmd == "" {
140+ return ""
141+ }
142+ fields := strings.Fields(cmd)
143+ if len(fields) == 0 {
144+ return ""
145+ }
146+ name := fields[0]
147+ for strings.Contains(name, "=") && len(fields) > 1 {
148+ fields = fields[1:]
149+ name = fields[0]
150+ }
151+ return filepath.Base(strings.Trim(name, "'\""))
152+}
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 +582 -0
@@ -0,0 +1,582 @@
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); 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 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")) {
151+ old := os.Getenv("ZOT_AGENT_SKILLS")
152+ v := filepath.Join(zf.Dir, "skills")
153+ if old != "" {
154+ v += string(os.PathListSeparator) + old
155+ }
156+ _ = os.Setenv("ZOT_AGENT_SKILLS", v)
157+ defer os.Setenv("ZOT_AGENT_SKILLS", old)
158+ }
159+ args.AgentName = zf.Manifest.Name
160+ args.AgentDataDir = agentData
161+ args.PermissionSet = &perms
162+ return runWithArgs(args, version)
163+}
164+
165+func zotInspect(ref string) error {
166+ zf, cleanup, err := loadZotfile(ref)
167+ if cleanup != nil {
168+ defer cleanup()
169+ }
170+ if err != nil {
171+ return err
172+ }
173+ m := zf.Manifest
174+ fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
175+ fmt.Println("\npermissions:")
176+ fmt.Print(permissionSummary(m.Permissions))
177+ fmt.Println("\nfiles:")
178+ return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
179+ if err != nil || path == zf.Dir {
180+ return err
181+ }
182+ rel, _ := filepath.Rel(zf.Dir, path)
183+ if d.IsDir() {
184+ rel += "/"
185+ }
186+ fmt.Println(" " + filepath.ToSlash(rel))
187+ return nil
188+ })
189+}
190+
191+func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
192+ minCtx := m.Model.MinContext
193+ if minCtx <= 0 {
194+ return nil
195+ }
196+ compatible := func(model provider.Model) bool {
197+ return model.ContextWindow >= minCtx
198+ }
199+ if args.Model != "" {
200+ model, err := provider.FindModel(args.Provider, args.Model)
201+ if err != nil {
202+ model, err = provider.FindModel("", args.Model)
203+ }
204+ if err != nil {
205+ return nil
206+ }
207+ if !compatible(model) {
208+ return fmt.Errorf("agent requires at least %d context tokens; %s has %d", minCtx, model.ID, model.ContextWindow)
209+ }
210+ return nil
211+ }
212+ cfg, _ := LoadConfig()
213+ if cfg.Model != "" {
214+ model, err := provider.FindModel(cfg.Provider, cfg.Model)
215+ if err == nil && compatible(model) {
216+ return nil
217+ }
218+ }
219+ for _, id := range m.Model.Preferred {
220+ model, err := provider.FindModel("", id)
221+ if err == nil && compatible(model) {
222+ args.Provider = model.Provider
223+ args.Model = model.ID
224+ return nil
225+ }
226+ }
227+ for _, model := range provider.Active() {
228+ if compatible(model) {
229+ args.Provider = model.Provider
230+ args.Model = model.ID
231+ return nil
232+ }
233+ }
234+ return fmt.Errorf("agent requires at least %d context tokens; no catalog model qualifies", minCtx)
235+}
236+
237+func prepareRuntimeCatalog() {
238+ LoadCachedModels()
239+ LoadUserModels()
240+ if cps := provider.CustomProviders(); len(cps) > 0 {
241+ var names []string
242+ for name := range cps {
243+ if !isBuiltinProvider(name) {
244+ names = append(names, name)
245+ }
246+ }
247+ auth.SetExtraAPIKeyProviders(names)
248+ }
249+ ValidateAndRepairConfig()
250+ RefreshModelsAsync()
251+}
252+
253+func zotPack(dir, out string) error {
254+ abs, err := filepath.Abs(dir)
255+ if err != nil {
256+ return err
257+ }
258+ if _, err := readZotManifest(abs); err != nil {
259+ return err
260+ }
261+ if _, err := os.Stat(filepath.Join(abs, "AGENT.md")); err != nil {
262+ return fmt.Errorf("AGENT.md is required: %w", err)
263+ }
264+ if out == "" {
265+ m, _ := readZotManifest(abs)
266+ base := safeAgentName(m.Name)
267+ if base == "" {
268+ base = filepath.Base(abs)
269+ }
270+ out = base + ".zot"
271+ }
272+ if filepath.Ext(out) != ".zot" {
273+ out += ".zot"
274+ }
275+ outAbs, err := filepath.Abs(out)
276+ if err != nil {
277+ return err
278+ }
279+ f, err := os.Create(out)
280+ if err != nil {
281+ return err
282+ }
283+ defer f.Close()
284+ h := sha256.New()
285+ mw := io.MultiWriter(f, h)
286+ enc, err := zstd.NewWriter(mw)
287+ if err != nil {
288+ return err
289+ }
290+ if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
291+ enc.Close()
292+ return err
293+ }
294+ if err := enc.Close(); err != nil {
295+ return err
296+ }
297+ fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
298+ return nil
299+}
300+
301+func loadZotfile(ref string) (zotfileLoaded, func(), error) {
302+ info, err := os.Stat(ref)
303+ if err != nil {
304+ return zotfileLoaded{}, nil, err
305+ }
306+ if info.IsDir() {
307+ abs, _ := filepath.Abs(ref)
308+ m, err := readZotManifest(abs)
309+ if err != nil {
310+ return zotfileLoaded{}, nil, err
311+ }
312+ return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
313+ }
314+ tmp, err := os.MkdirTemp("", "zotfile-*")
315+ if err != nil {
316+ return zotfileLoaded{}, nil, err
317+ }
318+ cleanup := func() { _ = os.RemoveAll(tmp) }
319+ digest, err := unpackZotArchive(ref, tmp)
320+ if err != nil {
321+ cleanup()
322+ return zotfileLoaded{}, nil, err
323+ }
324+ m, err := readZotManifest(tmp)
325+ if err != nil {
326+ cleanup()
327+ return zotfileLoaded{}, nil, err
328+ }
329+ return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
330+}
331+
332+func readZotManifest(dir string) (ZotfileManifest, error) {
333+ var m ZotfileManifest
334+ b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
335+ if err != nil {
336+ return m, fmt.Errorf("manifest.json is required: %w", err)
337+ }
338+ if err := json.Unmarshal(b, &m); err != nil {
339+ return m, fmt.Errorf("manifest.json: %w", err)
340+ }
341+ if m.Zotfile != 1 {
342+ return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
343+ }
344+ if strings.TrimSpace(m.Name) == "" {
345+ return m, fmt.Errorf("manifest name is required")
346+ }
347+ return m, nil
348+}
349+
350+func unpackZotArchive(path, dst string) (string, error) {
351+ b, err := os.ReadFile(path)
352+ if err != nil {
353+ return "", err
354+ }
355+ digest := sha256.Sum256(b)
356+ r, err := zstd.NewReader(bytes.NewReader(b))
357+ if err != nil {
358+ // Development fallback for older experiments.
359+ gr, gerr := gzip.NewReader(bytes.NewReader(b))
360+ if gerr != nil {
361+ return "", err
362+ }
363+ defer gr.Close()
364+ return hex.EncodeToString(digest[:]), untar(gr, dst)
365+ }
366+ defer r.Close()
367+ return hex.EncodeToString(digest[:]), untar(r, dst)
368+}
369+
370+func untar(r io.Reader, dst string) error {
371+ tr := tar.NewReader(r)
372+ for {
373+ h, err := tr.Next()
374+ if err == io.EOF {
375+ return nil
376+ }
377+ if err != nil {
378+ return err
379+ }
380+ name := filepath.Clean(h.Name)
381+ if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
382+ return fmt.Errorf("unsafe path in archive: %s", h.Name)
383+ }
384+ path := filepath.Join(dst, name)
385+ switch h.Typeflag {
386+ case tar.TypeDir:
387+ if err := os.MkdirAll(path, 0o755); err != nil {
388+ return err
389+ }
390+ case tar.TypeReg:
391+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
392+ return err
393+ }
394+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
395+ if err != nil {
396+ return err
397+ }
398+ _, cerr := io.Copy(f, tr)
399+ if err := f.Close(); err != nil && cerr == nil {
400+ cerr = err
401+ }
402+ if cerr != nil {
403+ return cerr
404+ }
405+ }
406+ }
407+}
408+
409+func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
410+ excluded := map[string]bool{}
411+ for _, p := range exclude {
412+ if p == "" {
413+ continue
414+ }
415+ abs, err := filepath.Abs(p)
416+ if err == nil {
417+ excluded[abs] = true
418+ }
419+ }
420+ var files []string
421+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
422+ if err != nil || path == root {
423+ return err
424+ }
425+ absPath, _ := filepath.Abs(path)
426+ if excluded[absPath] {
427+ return nil
428+ }
429+ if d.Type()&os.ModeSymlink != 0 {
430+ return fmt.Errorf("symlinks are not supported in zotfiles: %s", path)
431+ }
432+ files = append(files, path)
433+ return nil
434+ }); err != nil {
435+ return err
436+ }
437+ sort.Strings(files)
438+ tw := tar.NewWriter(w)
439+ defer tw.Close()
440+ fixed := time.Unix(0, 0)
441+ for _, path := range files {
442+ info, err := os.Stat(path)
443+ if err != nil {
444+ return err
445+ }
446+ rel, _ := filepath.Rel(root, path)
447+ rel = filepath.ToSlash(rel)
448+ h, err := tar.FileInfoHeader(info, "")
449+ if err != nil {
450+ return err
451+ }
452+ h.Name = rel
453+ h.ModTime = fixed
454+ h.AccessTime = fixed
455+ h.ChangeTime = fixed
456+ h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
457+ if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
458+ h.Name += "/"
459+ }
460+ if err := tw.WriteHeader(h); err != nil {
461+ return err
462+ }
463+ if info.Mode().IsRegular() {
464+ f, err := os.Open(path)
465+ if err != nil {
466+ return err
467+ }
468+ _, cerr := io.Copy(tw, f)
469+ _ = f.Close()
470+ if cerr != nil {
471+ return cerr
472+ }
473+ }
474+ }
475+ return nil
476+}
477+
478+func checkZotfileRequirements(zf zotfileLoaded) error {
479+ if len(zf.Manifest.Requirements.OS) > 0 {
480+ ok := false
481+ for _, osName := range zf.Manifest.Requirements.OS {
482+ if osName == runtime.GOOS {
483+ ok = true
484+ break
485+ }
486+ }
487+ if !ok {
488+ return fmt.Errorf("agent does not support %s", runtime.GOOS)
489+ }
490+ }
491+ if len(zf.Manifest.Requirements.Bin) > 0 {
492+ for _, b := range zf.Manifest.Requirements.Bin {
493+ if _, err := execLookPath(b); err != nil {
494+ return fmt.Errorf("agent requires binary %q", b)
495+ }
496+ }
497+ }
498+ return nil
499+}
500+
501+var execLookPath = exec.LookPath
502+
503+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
504+ if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
505+ return nil
506+ }
507+ fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
508+ fmt.Print(permissionSummary(perms))
509+ if !term.IsTerminal(int(os.Stdin.Fd())) {
510+ return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
511+ }
512+ fmt.Print("\nAllow? [y/N] ")
513+ var answer string
514+ _, _ = fmt.Fscanln(os.Stdin, &answer)
515+ answer = strings.ToLower(strings.TrimSpace(answer))
516+ if answer != "y" && answer != "yes" {
517+ return fmt.Errorf("declined")
518+ }
519+ return nil
520+}
521+
522+func permissionSummary(p tools.PermissionSet) string {
523+ var sb strings.Builder
524+ if len(p.FS.Read) > 0 {
525+ fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))
526+ }
527+ if len(p.FS.Write) > 0 {
528+ fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))
529+ }
530+ mode := p.Bash.Mode
531+ if mode == "" {
532+ mode = "none"
533+ }
534+ fmt.Fprintf(&sb, " bash: %s", mode)
535+ if len(p.Bash.Allow) > 0 {
536+ fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", "))
537+ }
538+ fmt.Fprintln(&sb)
539+ if len(p.Net.Allow) > 0 {
540+ fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
541+ }
542+ if len(p.Env.Read) > 0 {
543+ fmt.Fprintf(&sb, " env read: %s\n", strings.Join(p.Env.Read, ", "))
544+ }
545+ return sb.String()
546+}
547+
548+func bundledExtensionDirs(root string) []string {
549+ entries, err := os.ReadDir(root)
550+ if err != nil {
551+ return nil
552+ }
553+ var out []string
554+ for _, e := range entries {
555+ if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
556+ out = append(out, filepath.Join(root, e.Name()))
557+ }
558+ }
559+ return out
560+}
561+
562+func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
563+func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
564+
565+func safeAgentName(s string) string {
566+ s = strings.TrimSpace(strings.ToLower(s))
567+ s = strings.ReplaceAll(s, "/", "_")
568+ s = strings.ReplaceAll(s, "\\", "_")
569+ s = strings.Map(func(r rune) rune {
570+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
571+ return r
572+ }
573+ return '-'
574+ }, s)
575+ return strings.Trim(s, "-._")
576+}
577+
578+func digestDirectory(dir string) string {
579+ h := sha256.New()
580+ _ = writeCanonicalTar(dir, h)
581+ return hex.EncodeToString(h.Sum(nil))
582+}
@@ -0,0 +1,582 @@
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); 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 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")) {
151+ old := os.Getenv("ZOT_AGENT_SKILLS")
152+ v := filepath.Join(zf.Dir, "skills")
153+ if old != "" {
154+ v += string(os.PathListSeparator) + old
155+ }
156+ _ = os.Setenv("ZOT_AGENT_SKILLS", v)
157+ defer os.Setenv("ZOT_AGENT_SKILLS", old)
158+ }
159+ args.AgentName = zf.Manifest.Name
160+ args.AgentDataDir = agentData
161+ args.PermissionSet = &perms
162+ return runWithArgs(args, version)
163+}
164+
165+func zotInspect(ref string) error {
166+ zf, cleanup, err := loadZotfile(ref)
167+ if cleanup != nil {
168+ defer cleanup()
169+ }
170+ if err != nil {
171+ return err
172+ }
173+ m := zf.Manifest
174+ fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
175+ fmt.Println("\npermissions:")
176+ fmt.Print(permissionSummary(m.Permissions))
177+ fmt.Println("\nfiles:")
178+ return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
179+ if err != nil || path == zf.Dir {
180+ return err
181+ }
182+ rel, _ := filepath.Rel(zf.Dir, path)
183+ if d.IsDir() {
184+ rel += "/"
185+ }
186+ fmt.Println(" " + filepath.ToSlash(rel))
187+ return nil
188+ })
189+}
190+
191+func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
192+ minCtx := m.Model.MinContext
193+ if minCtx <= 0 {
194+ return nil
195+ }
196+ compatible := func(model provider.Model) bool {
197+ return model.ContextWindow >= minCtx
198+ }
199+ if args.Model != "" {
200+ model, err := provider.FindModel(args.Provider, args.Model)
201+ if err != nil {
202+ model, err = provider.FindModel("", args.Model)
203+ }
204+ if err != nil {
205+ return nil
206+ }
207+ if !compatible(model) {
208+ return fmt.Errorf("agent requires at least %d context tokens; %s has %d", minCtx, model.ID, model.ContextWindow)
209+ }
210+ return nil
211+ }
212+ cfg, _ := LoadConfig()
213+ if cfg.Model != "" {
214+ model, err := provider.FindModel(cfg.Provider, cfg.Model)
215+ if err == nil && compatible(model) {
216+ return nil
217+ }
218+ }
219+ for _, id := range m.Model.Preferred {
220+ model, err := provider.FindModel("", id)
221+ if err == nil && compatible(model) {
222+ args.Provider = model.Provider
223+ args.Model = model.ID
224+ return nil
225+ }
226+ }
227+ for _, model := range provider.Active() {
228+ if compatible(model) {
229+ args.Provider = model.Provider
230+ args.Model = model.ID
231+ return nil
232+ }
233+ }
234+ return fmt.Errorf("agent requires at least %d context tokens; no catalog model qualifies", minCtx)
235+}
236+
237+func prepareRuntimeCatalog() {
238+ LoadCachedModels()
239+ LoadUserModels()
240+ if cps := provider.CustomProviders(); len(cps) > 0 {
241+ var names []string
242+ for name := range cps {
243+ if !isBuiltinProvider(name) {
244+ names = append(names, name)
245+ }
246+ }
247+ auth.SetExtraAPIKeyProviders(names)
248+ }
249+ ValidateAndRepairConfig()
250+ RefreshModelsAsync()
251+}
252+
253+func zotPack(dir, out string) error {
254+ abs, err := filepath.Abs(dir)
255+ if err != nil {
256+ return err
257+ }
258+ if _, err := readZotManifest(abs); err != nil {
259+ return err
260+ }
261+ if _, err := os.Stat(filepath.Join(abs, "AGENT.md")); err != nil {
262+ return fmt.Errorf("AGENT.md is required: %w", err)
263+ }
264+ if out == "" {
265+ m, _ := readZotManifest(abs)
266+ base := safeAgentName(m.Name)
267+ if base == "" {
268+ base = filepath.Base(abs)
269+ }
270+ out = base + ".zot"
271+ }
272+ if filepath.Ext(out) != ".zot" {
273+ out += ".zot"
274+ }
275+ outAbs, err := filepath.Abs(out)
276+ if err != nil {
277+ return err
278+ }
279+ f, err := os.Create(out)
280+ if err != nil {
281+ return err
282+ }
283+ defer f.Close()
284+ h := sha256.New()
285+ mw := io.MultiWriter(f, h)
286+ enc, err := zstd.NewWriter(mw)
287+ if err != nil {
288+ return err
289+ }
290+ if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
291+ enc.Close()
292+ return err
293+ }
294+ if err := enc.Close(); err != nil {
295+ return err
296+ }
297+ fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
298+ return nil
299+}
300+
301+func loadZotfile(ref string) (zotfileLoaded, func(), error) {
302+ info, err := os.Stat(ref)
303+ if err != nil {
304+ return zotfileLoaded{}, nil, err
305+ }
306+ if info.IsDir() {
307+ abs, _ := filepath.Abs(ref)
308+ m, err := readZotManifest(abs)
309+ if err != nil {
310+ return zotfileLoaded{}, nil, err
311+ }
312+ return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
313+ }
314+ tmp, err := os.MkdirTemp("", "zotfile-*")
315+ if err != nil {
316+ return zotfileLoaded{}, nil, err
317+ }
318+ cleanup := func() { _ = os.RemoveAll(tmp) }
319+ digest, err := unpackZotArchive(ref, tmp)
320+ if err != nil {
321+ cleanup()
322+ return zotfileLoaded{}, nil, err
323+ }
324+ m, err := readZotManifest(tmp)
325+ if err != nil {
326+ cleanup()
327+ return zotfileLoaded{}, nil, err
328+ }
329+ return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
330+}
331+
332+func readZotManifest(dir string) (ZotfileManifest, error) {
333+ var m ZotfileManifest
334+ b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
335+ if err != nil {
336+ return m, fmt.Errorf("manifest.json is required: %w", err)
337+ }
338+ if err := json.Unmarshal(b, &m); err != nil {
339+ return m, fmt.Errorf("manifest.json: %w", err)
340+ }
341+ if m.Zotfile != 1 {
342+ return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
343+ }
344+ if strings.TrimSpace(m.Name) == "" {
345+ return m, fmt.Errorf("manifest name is required")
346+ }
347+ return m, nil
348+}
349+
350+func unpackZotArchive(path, dst string) (string, error) {
351+ b, err := os.ReadFile(path)
352+ if err != nil {
353+ return "", err
354+ }
355+ digest := sha256.Sum256(b)
356+ r, err := zstd.NewReader(bytes.NewReader(b))
357+ if err != nil {
358+ // Development fallback for older experiments.
359+ gr, gerr := gzip.NewReader(bytes.NewReader(b))
360+ if gerr != nil {
361+ return "", err
362+ }
363+ defer gr.Close()
364+ return hex.EncodeToString(digest[:]), untar(gr, dst)
365+ }
366+ defer r.Close()
367+ return hex.EncodeToString(digest[:]), untar(r, dst)
368+}
369+
370+func untar(r io.Reader, dst string) error {
371+ tr := tar.NewReader(r)
372+ for {
373+ h, err := tr.Next()
374+ if err == io.EOF {
375+ return nil
376+ }
377+ if err != nil {
378+ return err
379+ }
380+ name := filepath.Clean(h.Name)
381+ if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
382+ return fmt.Errorf("unsafe path in archive: %s", h.Name)
383+ }
384+ path := filepath.Join(dst, name)
385+ switch h.Typeflag {
386+ case tar.TypeDir:
387+ if err := os.MkdirAll(path, 0o755); err != nil {
388+ return err
389+ }
390+ case tar.TypeReg:
391+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
392+ return err
393+ }
394+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
395+ if err != nil {
396+ return err
397+ }
398+ _, cerr := io.Copy(f, tr)
399+ if err := f.Close(); err != nil && cerr == nil {
400+ cerr = err
401+ }
402+ if cerr != nil {
403+ return cerr
404+ }
405+ }
406+ }
407+}
408+
409+func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
410+ excluded := map[string]bool{}
411+ for _, p := range exclude {
412+ if p == "" {
413+ continue
414+ }
415+ abs, err := filepath.Abs(p)
416+ if err == nil {
417+ excluded[abs] = true
418+ }
419+ }
420+ var files []string
421+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
422+ if err != nil || path == root {
423+ return err
424+ }
425+ absPath, _ := filepath.Abs(path)
426+ if excluded[absPath] {
427+ return nil
428+ }
429+ if d.Type()&os.ModeSymlink != 0 {
430+ return fmt.Errorf("symlinks are not supported in zotfiles: %s", path)
431+ }
432+ files = append(files, path)
433+ return nil
434+ }); err != nil {
435+ return err
436+ }
437+ sort.Strings(files)
438+ tw := tar.NewWriter(w)
439+ defer tw.Close()
440+ fixed := time.Unix(0, 0)
441+ for _, path := range files {
442+ info, err := os.Stat(path)
443+ if err != nil {
444+ return err
445+ }
446+ rel, _ := filepath.Rel(root, path)
447+ rel = filepath.ToSlash(rel)
448+ h, err := tar.FileInfoHeader(info, "")
449+ if err != nil {
450+ return err
451+ }
452+ h.Name = rel
453+ h.ModTime = fixed
454+ h.AccessTime = fixed
455+ h.ChangeTime = fixed
456+ h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
457+ if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
458+ h.Name += "/"
459+ }
460+ if err := tw.WriteHeader(h); err != nil {
461+ return err
462+ }
463+ if info.Mode().IsRegular() {
464+ f, err := os.Open(path)
465+ if err != nil {
466+ return err
467+ }
468+ _, cerr := io.Copy(tw, f)
469+ _ = f.Close()
470+ if cerr != nil {
471+ return cerr
472+ }
473+ }
474+ }
475+ return nil
476+}
477+
478+func checkZotfileRequirements(zf zotfileLoaded) error {
479+ if len(zf.Manifest.Requirements.OS) > 0 {
480+ ok := false
481+ for _, osName := range zf.Manifest.Requirements.OS {
482+ if osName == runtime.GOOS {
483+ ok = true
484+ break
485+ }
486+ }
487+ if !ok {
488+ return fmt.Errorf("agent does not support %s", runtime.GOOS)
489+ }
490+ }
491+ if len(zf.Manifest.Requirements.Bin) > 0 {
492+ for _, b := range zf.Manifest.Requirements.Bin {
493+ if _, err := execLookPath(b); err != nil {
494+ return fmt.Errorf("agent requires binary %q", b)
495+ }
496+ }
497+ }
498+ return nil
499+}
500+
501+var execLookPath = exec.LookPath
502+
503+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
504+ if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
505+ return nil
506+ }
507+ fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
508+ fmt.Print(permissionSummary(perms))
509+ if !term.IsTerminal(int(os.Stdin.Fd())) {
510+ return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
511+ }
512+ fmt.Print("\nAllow? [y/N] ")
513+ var answer string
514+ _, _ = fmt.Fscanln(os.Stdin, &answer)
515+ answer = strings.ToLower(strings.TrimSpace(answer))
516+ if answer != "y" && answer != "yes" {
517+ return fmt.Errorf("declined")
518+ }
519+ return nil
520+}
521+
522+func permissionSummary(p tools.PermissionSet) string {
523+ var sb strings.Builder
524+ if len(p.FS.Read) > 0 {
525+ fmt.Fprintf(&sb, " fs read: %s\n", strings.Join(p.FS.Read, ", "))
526+ }
527+ if len(p.FS.Write) > 0 {
528+ fmt.Fprintf(&sb, " fs write: %s\n", strings.Join(p.FS.Write, ", "))
529+ }
530+ mode := p.Bash.Mode
531+ if mode == "" {
532+ mode = "none"
533+ }
534+ fmt.Fprintf(&sb, " bash: %s", mode)
535+ if len(p.Bash.Allow) > 0 {
536+ fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", "))
537+ }
538+ fmt.Fprintln(&sb)
539+ if len(p.Net.Allow) > 0 {
540+ fmt.Fprintf(&sb, " net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
541+ }
542+ if len(p.Env.Read) > 0 {
543+ fmt.Fprintf(&sb, " env read: %s\n", strings.Join(p.Env.Read, ", "))
544+ }
545+ return sb.String()
546+}
547+
548+func bundledExtensionDirs(root string) []string {
549+ entries, err := os.ReadDir(root)
550+ if err != nil {
551+ return nil
552+ }
553+ var out []string
554+ for _, e := range entries {
555+ if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
556+ out = append(out, filepath.Join(root, e.Name()))
557+ }
558+ }
559+ return out
560+}
561+
562+func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
563+func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
564+
565+func safeAgentName(s string) string {
566+ s = strings.TrimSpace(strings.ToLower(s))
567+ s = strings.ReplaceAll(s, "/", "_")
568+ s = strings.ReplaceAll(s, "\\", "_")
569+ s = strings.Map(func(r rune) rune {
570+ if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
571+ return r
572+ }
573+ return '-'
574+ }, s)
575+ return strings.Trim(s, "-._")
576+}
577+
578+func digestDirectory(dir string) string {
579+ h := sha256.New()
580+ _ = writeCanonicalTar(dir, h)
581+ return hex.EncodeToString(h.Sum(nil))
582+}

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