1package tools
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7
8 "github.com/bnema/zut/packages/core"
9)
10
11// denied marks a policy refusal (permission scope, allowlist, jail
12// confinement, or admission gate) so core can distinguish it from an
13// ordinary tool failure. The message text shown to the model is unchanged.
14func denied(format string, args ...any) error {
15 return &core.ToolDeniedError{Reason: fmt.Sprintf(format, args...)}
16}
17
18// PermissionSet is the local-runtime permission contract for a packaged .zut agent.
19type PermissionSet struct {
20 FS struct {
21 Read []string `json:"read"`
22 Write []string `json:"write"`
23 } `json:"fs"`
24 Bash struct {
25 Mode string `json:"mode"`
26 Allow []string `json:"allow"`
27 } `json:"bash"`
28 Net struct {
29 Allow []string `json:"allow"`
30 } `json:"net"`
31 Env struct {
32 Read []string `json:"read"`
33 } `json:"env"`
34}
35
36// Expand resolves manifest variables into absolute local paths.
37func (p PermissionSet) Expand(workspace, agentData string) PermissionSet {
38 expand := func(s string) string {
39 s = strings.ReplaceAll(s, "${workspace}", workspace)
40 s = strings.ReplaceAll(s, "${agent_data}", agentData)
41 if !filepath.IsAbs(s) && s != "" {
42 s = filepath.Join(workspace, s)
43 }
44 return s
45 }
46 // Clone the slices before expanding them. PermissionSet is copied by value,
47 // but slices otherwise still alias the manifest and repeated expansion (for
48 // example after /cd) would mutate the original declaration.
49 p.FS.Read = append([]string(nil), p.FS.Read...)
50 p.FS.Write = append([]string(nil), p.FS.Write...)
51 for i, v := range p.FS.Read {
52 p.FS.Read[i] = expand(v)
53 }
54 for i, v := range p.FS.Write {
55 p.FS.Write[i] = expand(v)
56 }
57 return p
58}
59
60func (s *Sandbox) SetPermissions(p *PermissionSet) {
61 if s == nil {
62 return
63 }
64 s.Permissions = p
65}
66
67func (s *Sandbox) CheckReadPath(path string) error {
68 if err := s.CheckPath(path); err != nil {
69 return err
70 }
71 if s == nil || s.Permissions == nil {
72 return nil
73 }
74 if len(s.Permissions.FS.Read) == 0 {
75 return denied("permission denied: this agent has no filesystem read permission")
76 }
77 return checkScopedPath("read", path, s.Permissions.FS.Read)
78}
79
80func (s *Sandbox) CheckWritePath(path string) error {
81 if err := s.CheckPath(path); err != nil {
82 return err
83 }
84 if s == nil || s.Permissions == nil {
85 return nil
86 }
87 if len(s.Permissions.FS.Write) == 0 {
88 return denied("permission denied: this agent has no filesystem write permission")
89 }
90 return checkScopedPath("write", path, s.Permissions.FS.Write)
91}
92
93func checkScopedPath(op, path string, scopes []string) error {
94 target, err := canonicalOrParent(path)
95 if err != nil {
96 return fmt.Errorf("permission path: %w", err)
97 }
98 for _, scope := range scopes {
99 root, err := canonicalOrParent(scope)
100 if err != nil {
101 continue
102 }
103 if isUnder(root, target) {
104 return nil
105 }
106 }
107 return denied("permission denied: %s %q is outside declared scopes", op, path)
108}
109
110func (s *Sandbox) CheckBashPermission(cmd string) error {
111 if s == nil || s.Permissions == nil {
112 return nil
113 }
114 mode := strings.ToLower(strings.TrimSpace(s.Permissions.Bash.Mode))
115 if mode == "" {
116 mode = "none"
117 }
118 switch mode {
119 case "none":
120 return denied("permission denied: this agent has no bash permission")
121 case "ask":
122 // The local runtime obtains explicit consent for this capability at
123 // every launch. Unlike durable consent, ask mode is never cached.
124 return nil
125 case "allowlist":
126 if strings.ContainsAny(cmd, "`\n\r<>") || strings.Contains(cmd, "$(") {
127 return denied("permission denied: bash allowlist does not permit substitution or redirection")
128 }
129 names := commandNames(cmd)
130 if len(names) == 0 {
131 return denied("permission denied: empty command")
132 }
133 allowed := map[string]bool{}
134 for _, name := range s.Permissions.Bash.Allow {
135 allowed[filepath.Base(strings.TrimSpace(name))] = true
136 }
137 for _, name := range names {
138 if !allowed[name] {
139 return denied("permission denied: bash command %q is not in allowlist", name)
140 }
141 }
142 return nil
143 default:
144 return denied("permission denied: unsupported bash mode %q", mode)
145 }
146}
147
148func commandNames(cmd string) []string {
149 cmd = strings.NewReplacer("&&", ";", "||", ";", "|", ";").Replace(cmd)
150 parts := strings.Split(cmd, ";")
151 var out []string
152 for _, part := range parts {
153 name := firstCommandName(part)
154 if name == "" || name == "cd" {
155 continue
156 }
157 out = append(out, name)
158 }
159 return out
160}
161
162func firstCommandName(cmd string) string {
163 cmd = strings.TrimSpace(cmd)
164 if cmd == "" {
165 return ""
166 }
167 fields := strings.Fields(cmd)
168 if len(fields) == 0 {
169 return ""
170 }
171 name := fields[0]
172 for strings.Contains(name, "=") && len(fields) > 1 {
173 fields = fields[1:]
174 name = fields[0]
175 }
176 return filepath.Base(strings.Trim(name, "'\""))
177}