| 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 |