mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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.
140 lines
4.2 KiB
Go
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
|
|
}
|