Files
DeepSeek-Reasonix/internal/contract/config/project_grants.go
T
YHH 23f0a011c4 fix(config): workspace configuration only narrows the user's settings (#11208)
A workspace's reasonix.toml and .reasonix/settings.json are read as
untrusted input.

- [sandbox]: bash may only move to enforce and network only to false;
  forbid_read and denied_domains add to the user's; allow_write entries
  and workspace_root apply only when they resolve inside the workspace.
  ${VAR} in sandbox and proxy paths expands from the process environment
  only.
- [permissions] mode, allow and allow_dynamic_bash, [network] proxy
  settings and [desktop] default_tool_approval_mode are read from the
  user config only; deny and ask add to the user's. An "always allow"
  given in a workspace is stored under the Reasonix home
  (project-grants.json) rather than in the checkout.
- Programs a workspace declares (hooks, language servers, providers,
  tools.search.rg_path, tools.shell.path, [browser], tools.system_one
  endpoints and the local Laya runtime) run only once approved for that
  folder. An approval records the declaration and a digest of the files
  it names, is kept under the Reasonix home, and is checked again before
  each start; a change refuses the start with ErrProjectProgramChanged.
  `reasonix trust` lists and approves them, and saving hooks from the
  hooks page approves the block written. Model settings that named only
  a held-back provider fall back to the user's choice.
- Single program paths may not point into the workspace, a temporary
  directory, an allow_write root or a toolchain cache.
- Sandbox write roots and host write directories keep the identity they
  had when first granted; a root whose identity changed is dropped with
  sandbox.write_root_redirected or sandbox.write_root_changed. The
  host-writable directory list lives in internal/base/hostwrite.
- Every ignored value is reported as a load warning with a typed reason
  (Config.IgnoredProjectSettings); held-back programs raise the
  project_programs_awaiting_approval notice.
2026-09-28 18:21:34 -07:00

140 lines
4.2 KiB
Go

package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"reasonix/internal/base/filelock"
"reasonix/internal/base/fileutil"
"reasonix/internal/base/workspaceid"
"reasonix/internal/contract/permrule"
)
// ProjectGrant is what the user granted one workspace folder: allow rules
// answered "always" and extra writable directories. It lives under the user's
// home, keyed by the folder's own path: a grant has no digest to bind it, so it
// must not follow a repository identity a checkout's .git file could claim.
type ProjectGrant struct {
Allow []string `json:"allow,omitempty"`
AllowWrite []string `json:"allow_write,omitempty"`
}
type projectGrantFile struct {
Version int `json:"version"`
Workspaces map[string]ProjectGrant `json:"workspaces"`
}
const (
projectGrantsFilename = "project-grants.json"
projectGrantsLockFile = ".project-grants.lock"
projectGrantsVersion = 1
)
// ErrProjectGrantsUnavailable is a record that could not be read or written.
var ErrProjectGrantsUnavailable = errors.New("workspace grants unavailable")
// ProjectGrantStore persists grants in <Reasonix home>/project-grants.json.
type ProjectGrantStore struct {
path string
}
// NewProjectGrantStore opens the record under home.
func NewProjectGrantStore(home string) *ProjectGrantStore {
if strings.TrimSpace(home) == "" {
return &ProjectGrantStore{}
}
return &ProjectGrantStore{path: filepath.Join(home, projectGrantsFilename)}
}
// Path is the file the grants are written to.
func (s *ProjectGrantStore) Path() string { return s.path }
// Grant returns what root was granted.
func (s *ProjectGrantStore) Grant(root string) (ProjectGrant, error) {
file, err := s.load()
if err != nil {
return ProjectGrant{}, err
}
return file.Workspaces[workspaceid.PathFingerprint(root)], nil
}
// Update replaces root's grant with what edit returns, under a cross-process lock.
func (s *ProjectGrantStore) Update(root string, edit func(ProjectGrant) (ProjectGrant, error)) error {
ws := workspaceid.PathFingerprint(root)
if s.path == "" || ws == "" {
return fmt.Errorf("%w: no Reasonix home or workspace", ErrProjectGrantsUnavailable)
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
unlock, err := filelock.Acquire(ctx, filepath.Join(filepath.Dir(s.path), projectGrantsLockFile))
if err != nil {
return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err)
}
defer unlock()
file, err := s.load()
if err != nil {
return err
}
current := file.Workspaces[ws]
next, err := edit(ProjectGrant{Allow: slices.Clone(current.Allow), AllowWrite: slices.Clone(current.AllowWrite)})
if err != nil {
return err
}
for _, rule := range next.Allow {
if _, ok := permrule.Parse(rule); !ok {
return fmt.Errorf("invalid permission rule %q (want \"ToolName\" or \"ToolName(glob)\")", rule)
}
}
file.Workspaces[ws] = next
file.Version = projectGrantsVersion
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err)
}
if err := fileutil.AtomicWriteFile(s.path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err)
}
return nil
}
func (s *ProjectGrantStore) load() (projectGrantFile, error) {
file := projectGrantFile{Version: projectGrantsVersion, Workspaces: map[string]ProjectGrant{}}
if s.path == "" {
return file, nil
}
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return file, nil
}
if err != nil {
return file, fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err)
}
if err := json.Unmarshal(data, &file); err != nil {
return file, fmt.Errorf("%w: %s: %w", ErrProjectGrantsUnavailable, s.path, err)
}
if file.Workspaces == nil {
file.Workspaces = map[string]ProjectGrant{}
}
return file, nil
}
// grantedToWorkspace is best effort: an unreadable record grants nothing,
// which costs a prompt rather than an approval nobody gave.
func grantedToWorkspace(home, ws string) ProjectGrant {
if ws == "" {
return ProjectGrant{}
}
grant, _ := NewProjectGrantStore(home).Grant(ws)
return grant
}