返回 DeepSeek-Reasonix
project_programs.go
根目录 / internal / config / project_programs.go
1 package config
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "maps"
12 "os"
13 "path/filepath"
14 "reflect"
15 "slices"
16 "strings"
17 "sync"
18 "time"
19
20 "reasonix/internal/filelock"
21 "reasonix/internal/fileutil"
22 "reasonix/internal/workspaceid"
23 )
24
25 // A project file can name programs the host runs: hooks, language servers,
26 // ripgrep, the shell. Each runs only once the user approved that exact
27 // declaration; approvals live under the user's home, never in the checkout,
28 // keyed by workspace folder and content digest.
29 const (
30 projectProgramsFilename = "project-programs.json"
31 projectProgramsLockFile = ".project-programs.lock"
32 projectProgramsVersion = 1
33 )
34
35 // ProjectProgramKind names which kind of host-run program a project declares.
36 type ProjectProgramKind string
37
38 const (
39 ProjectProgramHooks ProjectProgramKind = "hooks"
40 ProjectProgramLSP ProjectProgramKind = "lsp"
41 ProjectProgramRipgrep ProjectProgramKind = "rg_path"
42 ProjectProgramShell ProjectProgramKind = "shell_path"
43 ProjectProgramBrowser ProjectProgramKind = "browser"
44 )
45
46 // ErrProjectProgramsUnavailable is a record that could not be read or written.
47 var ErrProjectProgramsUnavailable = errors.New("project program approvals unavailable")
48
49 // ProjectProgram is one project-declared program as the user would approve it.
50 // Detail is a one-line summary; Declaration and Files are everything Digest
51 // covers, so what a person is shown to approve is exactly what is checked.
52 type ProjectProgram struct {
53 Kind ProjectProgramKind `json:"kind"`
54 Name string `json:"name"`
55 Detail string `json:"detail"`
56 Declaration string `json:"declaration"`
57 Files []string `json:"files,omitempty"`
58 Digest string `json:"digest"`
59 }
60
61 // NewProjectProgram digests decl together with every word in words that names
62 // a regular file inside ws (relative words resolve against base).
63 func NewProjectProgram(kind ProjectProgramKind, name, detail, ws, base string, decl any, words []string) ProjectProgram {
64 body, _ := json.Marshal(struct {
65 Kind ProjectProgramKind
66 Name string
67 Decl any
68 }{kind, name, decl})
69 if strings.TrimSpace(detail) == "" {
70 detail = name
71 }
72 p := ProjectProgram{Kind: kind, Name: name, Detail: detail, Declaration: string(body), Files: workspaceFilesNamed(ws, base, words)}
73 p.Digest = p.currentDigest()
74 return p
75 }
76
77 // currentDigest hashes the declaration and the files' content as they are now;
78 // a file that can no longer be read hashes differently from its approved self.
79 func (p ProjectProgram) currentDigest() string {
80 h := sha256.New()
81 h.Write([]byte(p.Declaration))
82 for _, file := range p.Files {
83 fmt.Fprintf(h, "\x00%s\x00", file)
84 if f, err := os.Open(file); err == nil {
85 _, _ = io.Copy(h, f)
86 _ = f.Close()
87 } else {
88 h.Write([]byte("\x00unreadable"))
89 }
90 }
91 return hex.EncodeToString(h.Sum(nil))
92 }
93
94 // workspaceFilesNamed lists the files words name that what runs in the
95 // workspace could rewrite: any relative path, taken as the OS resolves it
96 // (a link, then ".."), and any absolute one in the workspace or a directory
97 // the bash jail leaves writable. A path missing now is still listed, so
98 // creating it later changes the digest.
99 func workspaceFilesNamed(ws, base string, words []string) []string {
100 if ws == "" {
101 return nil
102 }
103 if base == "" {
104 base = ws
105 }
106 var out []string
107 for _, word := range namedWords(words) {
108 path := word
109 if !filepath.IsAbs(path) {
110 path = base + string(filepath.Separator) + word
111 } else if !strings.Contains(word, "..") && !inRoots(path, append([]string{ws, os.TempDir()}, hostWritableDirs()...)) {
112 continue
113 }
114 info, err := os.Stat(path)
115 if err == nil && !info.Mode().IsRegular() || err != nil && !strings.ContainsAny(word, `/\.`) {
116 continue
117 }
118 if !slices.Contains(out, path) {
119 out = append(out, path)
120 }
121 }
122 return out
123 }
124
125 // namedWords yields each word and, for a flag like --require=./x, its value.
126 func namedWords(words []string) []string {
127 var out []string
128 for _, word := range words {
129 word = strings.TrimSpace(word)
130 if word == "" {
131 continue
132 }
133 out = append(out, word)
134 if _, value, ok := strings.Cut(word, "="); ok && strings.TrimSpace(value) != "" {
135 out = append(out, strings.TrimSpace(value))
136 }
137 }
138 return out
139 }
140
141 type programApproval struct {
142 Workspace string `json:"workspace"`
143 Kind ProjectProgramKind `json:"kind"`
144 Name string `json:"name"`
145 Digest string `json:"digest"`
146 ApprovedAt time.Time `json:"approved_at"`
147 }
148
149 type programApprovalFile struct {
150 Version int `json:"version"`
151 Approvals []programApproval `json:"approvals"`
152 }
153
154 // ProjectProgramStore persists approvals in <Reasonix home>/project-programs.json.
155 type ProjectProgramStore struct {
156 path string
157 mu sync.Mutex
158 }
159
160 // NewProjectProgramStore opens the approval record under home.
161 func NewProjectProgramStore(home string) *ProjectProgramStore {
162 if strings.TrimSpace(home) == "" {
163 return &ProjectProgramStore{}
164 }
165 return &ProjectProgramStore{path: filepath.Join(home, projectProgramsFilename)}
166 }
167
168 // Approved reports whether p, exactly as digested, was approved for root.
169 func (s *ProjectProgramStore) Approved(root string, p ProjectProgram) (bool, error) {
170 file, err := s.load()
171 if err != nil {
172 return false, err
173 }
174 ws := workspaceid.PathFingerprint(root)
175 return slices.ContainsFunc(file.Approvals, func(a programApproval) bool {
176 return ws != "" && a.Workspace == ws && a.Kind == p.Kind && a.Name == p.Name && a.Digest == p.Digest
177 }), nil
178 }
179
180 // Approve records programs for root, replacing an earlier digest of the same one.
181 func (s *ProjectProgramStore) Approve(root string, programs ...ProjectProgram) error {
182 ws := workspaceid.PathFingerprint(root)
183 if ws == "" {
184 return fmt.Errorf("%w: no workspace to approve for", ErrProjectProgramsUnavailable)
185 }
186 return s.update(func(file *programApprovalFile) {
187 for _, p := range programs {
188 file.Approvals = slices.DeleteFunc(file.Approvals, func(a programApproval) bool {
189 return a.Workspace == ws && a.Kind == p.Kind && a.Name == p.Name
190 })
191 file.Approvals = append(file.Approvals, programApproval{Workspace: ws, Kind: p.Kind, Name: p.Name, Digest: p.Digest, ApprovedAt: time.Now().UTC()})
192 }
193 })
194 }
195
196 // Revoke drops every approval recorded for root.
197 func (s *ProjectProgramStore) Revoke(root string) error {
198 ws := workspaceid.PathFingerprint(root)
199 return s.update(func(file *programApprovalFile) {
200 file.Approvals = slices.DeleteFunc(file.Approvals, func(a programApproval) bool { return a.Workspace == ws })
201 })
202 }
203
204 func (s *ProjectProgramStore) load() (programApprovalFile, error) {
205 if s == nil || s.path == "" {
206 return programApprovalFile{}, fmt.Errorf("%w: no Reasonix home", ErrProjectProgramsUnavailable)
207 }
208 data, err := os.ReadFile(s.path)
209 if os.IsNotExist(err) {
210 return programApprovalFile{Version: projectProgramsVersion}, nil
211 }
212 if err != nil {
213 return programApprovalFile{}, fmt.Errorf("%w: %w", ErrProjectProgramsUnavailable, err)
214 }
215 var file programApprovalFile
216 if err := json.Unmarshal(data, &file); err != nil {
217 return programApprovalFile{}, fmt.Errorf("%w: %s: %w", ErrProjectProgramsUnavailable, s.path, err)
218 }
219 return file, nil
220 }
221
222 func (s *ProjectProgramStore) update(mutate func(*programApprovalFile)) error {
223 if s == nil || s.path == "" {
224 return fmt.Errorf("%w: no Reasonix home", ErrProjectProgramsUnavailable)
225 }
226 s.mu.Lock()
227 defer s.mu.Unlock()
228 if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
229 return fmt.Errorf("%w: %w", ErrProjectProgramsUnavailable, err)
230 }
231 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
232 defer cancel()
233 unlock, err := filelock.Acquire(ctx, filepath.Join(filepath.Dir(s.path), projectProgramsLockFile))
234 if err != nil {
235 return fmt.Errorf("%w: %w", ErrProjectProgramsUnavailable, err)
236 }
237 defer unlock()
238 file, err := s.load()
239 if err != nil {
240 return err
241 }
242 mutate(&file)
243 file.Version = projectProgramsVersion
244 data, err := json.MarshalIndent(file, "", " ")
245 if err != nil {
246 return fmt.Errorf("%w: %w", ErrProjectProgramsUnavailable, err)
247 }
248 if err := fileutil.AtomicWriteFile(s.path, append(data, '\n'), 0o600); err != nil {
249 return fmt.Errorf("%w: %w", ErrProjectProgramsUnavailable, err)
250 }
251 return nil
252 }
253
254 // PendingProjectPrograms lists the project-declared programs this load held
255 // back because nobody approved them.
256 func (c *Config) PendingProjectPrograms() []ProjectProgram {
257 if c == nil {
258 return nil
259 }
260 return slices.Clone(c.projectScope.pending)
261 }
262
263 // admit reports whether a project program may run, recording why not.
264 func (c *Config) admit(store *ProjectProgramStore, ws, key string, p ProjectProgram) bool {
265 ok, err := store.Approved(ws, p)
266 switch {
267 case err != nil:
268 c.ignoreProject(key, p.Detail, ProjectApprovalUnavailable)
269 case !ok:
270 c.ignoreProject(key, p.Detail, ProjectAwaitingApproval)
271 }
272 if ok {
273 c.projectScope.admitted = append(c.projectScope.admitted, p)
274 } else {
275 c.projectScope.pending = append(c.projectScope.pending, p)
276 }
277 return ok
278 }
279
280 // gatePrograms keeps a project-chosen shell, ripgrep and language server only
281 // once approved; otherwise the user's own value stays in force.
282 func (h heldScope) gatePrograms(c *Config, store *ProjectProgramStore, ws string) {
283 singles := []struct {
284 kind ProjectProgramKind
285 key string
286 v *string
287 held string
288 }{
289 {ProjectProgramShell, "tools.shell.path", &c.Tools.Shell.Path, h.shell.Path},
290 {ProjectProgramRipgrep, "tools.search.rg_path", &c.Tools.Search.RgPath, h.rgPath},
291 {ProjectProgramBrowser, "browser.chrome_path", &c.Browser.ChromePath, h.browser.ChromePath},
292 }
293 for _, one := range singles {
294 if *one.v == one.held {
295 continue
296 }
297 // A single binary where sandboxed commands can write could be replaced
298 // after it was approved and before the host starts it again.
299 if c.namesWritablePath(ws, *one.v) {
300 c.ignoreProject(one.key, *one.v, ProjectProgramWritable)
301 *one.v = one.held
302 continue
303 }
304 p := newSingleProgram(one.kind, one.key, *one.v, ws)
305 if !c.admit(store, ws, one.key, p) {
306 *one.v = one.held
307 }
308 }
309 if launch, held := browserLaunch(c.Browser), browserLaunch(h.browser); !reflect.DeepEqual(launch, held) {
310 p := NewProjectProgram(ProjectProgramBrowser, "browser", c.Browser.ChromePath, ws, ws, launch, c.Browser.ChromeArgs)
311 if !c.admit(store, ws, "browser", p) {
312 c.Browser.Endpoint, c.Browser.AllowRemoteEndpoint = h.browser.Endpoint, h.browser.AllowRemoteEndpoint
313 c.Browser.ChromeArgs, c.Browser.UserDataDir = h.browser.ChromeArgs, h.browser.UserDataDir
314 }
315 }
316 servers := maps.Clone(h.lsp)
317 for _, lang := range slices.Sorted(maps.Keys(c.LSP.Servers)) {
318 srv := c.LSP.Servers[lang]
319 if user, ok := h.lsp[lang]; ok && reflect.DeepEqual(user, srv) {
320 continue
321 }
322 detail := strings.TrimSpace(strings.Join(append([]string{srv.Command}, srv.Args...), " ") + EnvSummary(srv.Env))
323 p := NewProjectProgram(ProjectProgramLSP, lang, detail, ws, ws, srv, append([]string{srv.Command}, srv.Args...))
324 if c.admit(store, ws, "lsp.servers."+lang, p) {
325 if servers == nil {
326 servers = map[string]LSPServer{}
327 }
328 servers[lang] = srv
329 }
330 }
331 c.LSP.Servers = servers
332 }
333
334 // ApproveWorkspacePrograms approves every program root's configuration names,
335 // as it stands now, and returns them. It is what a person confirming the whole
336 // list does; project hooks are the hook package's to approve.
337 func ApproveWorkspacePrograms(root string) ([]ProjectProgram, error) {
338 cfg, err := LoadForRootReadOnly(root)
339 if err != nil {
340 return nil, err
341 }
342 pending := cfg.PendingProjectPrograms()
343 if len(pending) == 0 {
344 return nil, nil
345 }
346 return pending, NewProjectProgramStore(reasonixHomeDir()).Approve(root, pending...)
347 }
348
349 // EnvSummary renders env for a person approving it, in a stable order.
350 func EnvSummary(env map[string]string) string {
351 var b strings.Builder
352 for _, k := range slices.Sorted(maps.Keys(env)) {
353 fmt.Fprintf(&b, " %s=%s", k, env[k])
354 }
355 return b.String()
356 }
357
358 // browserLaunch is what decides which browser runs, or which one is driven.
359 func browserLaunch(b BrowserConfig) BrowserConfig {
360 b.Enabled, b.Headless, b.ChromePath = false, false, ""
361 return b
362 }
363
363 lines GO